openaxies 0.4.0 → 0.5.1

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,30 +1,20 @@
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';
11
7
  import { createComposerDock, DOCK_HEIGHT } from './components/ComposerDock.js';
12
8
 
13
9
  const h = React.createElement;
14
10
 
15
11
  export default AppRoot;
16
12
 
17
- // ---------------------------------------------------------------------------
18
- // Helpers
19
- // ---------------------------------------------------------------------------
20
-
21
13
  function buildHistory(messages, newText) {
22
14
  const history = [];
23
15
  for (let i = 0; i < messages.length; i++) {
24
16
  const m = messages[i];
25
- if (m === null || m === undefined || typeof m !== 'object') {
26
- continue;
27
- }
17
+ if (m === null || m === undefined || typeof m !== 'object') continue;
28
18
  if (m.role === 'user' || m.role === 'assistant') {
29
19
  const content = typeof m.content === 'string' ? m.content : '';
30
20
  history.push({ role: m.role, content: content });
@@ -36,49 +26,47 @@ function buildHistory(messages, newText) {
36
26
  return history;
37
27
  }
38
28
 
39
- // ---------------------------------------------------------------------------
40
- // Root component
41
- // ---------------------------------------------------------------------------
29
+ function formatTimer(seconds) {
30
+ if (typeof seconds !== 'number' || seconds < 0) return '0.0s';
31
+ return seconds.toFixed(1) + 's';
32
+ }
33
+
34
+ function hr(cols) {
35
+ const n = typeof cols === 'number' && cols > 0 ? cols : 80;
36
+ let s = '';
37
+ for (let i = 0; i < n; i++) s = s + '\u2500';
38
+ return h(Text, { color: '#1A1A28' }, s);
39
+ }
40
+
41
+ const SPINNER = ['\u25CB', '\u25D4', '\u25D0', '\u25D5', '\u25CF', '\u25D5', '\u25D0', '\u25D4'];
42
42
 
43
43
  function AppRoot() {
44
44
  const { stdout } = useStdout();
45
45
  const rows = stdout.rows || 24;
46
46
  const cols = stdout.columns || 80;
47
47
 
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
48
  const [messages, setMessages] = React.useState([]);
55
49
  const [activeModel, setActiveModel] = React.useState(DEFAULT_MODEL_ID);
56
50
  const [streamText, setStreamText] = React.useState('');
57
51
  const [streamingActive, setStreamingActive] = React.useState(false);
58
- const [scrollOffset, setScrollOffset] = React.useState(0);
59
52
  const [isThinking, setIsThinking] = React.useState(false);
60
- const [spinnerChar, setSpinnerChar] = React.useState('');
53
+ const [spinnerIdx, setSpinnerIdx] = React.useState(0);
54
+ const [thinkingElapsed, setThinkingElapsed] = React.useState(0);
55
+ const [inputBuffer, setInputBuffer] = React.useState('');
56
+ const [showOverlay, setShowOverlay] = React.useState(false);
57
+ const [overlayIndex, setOverlayIndex] = React.useState(0);
58
+ const [toolInfo, setToolInfo] = React.useState(null);
61
59
 
62
60
  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;
61
+ const overlayH = showOverlay ? 6 : 0;
62
+ const fixedH = 1 + overlayH + DOCK_HEIGHT;
74
63
  const availLines = Math.max(1, rows - fixedH);
75
64
 
76
65
  const abortRef = React.useRef(null);
77
- const connectingRef = React.useRef(null);
66
+ const connRef = React.useRef(null);
78
67
  const spinnerRef = React.useRef(null);
79
- const thinkingAccumRef = React.useRef('');
80
-
81
- const SPINNER_FRAMES = ['◐', '◓', '◑', '◒'];
68
+ const timerRef = React.useRef(null);
69
+ const thinkStartRef = React.useRef(null);
82
70
 
83
71
  React.useEffect(function () {
84
72
  if (isThinking === false) {
@@ -86,255 +74,217 @@ function AppRoot() {
86
74
  clearInterval(spinnerRef.current);
87
75
  spinnerRef.current = null;
88
76
  }
89
- setSpinnerChar('');
77
+ if (timerRef.current !== null) {
78
+ clearInterval(timerRef.current);
79
+ timerRef.current = null;
80
+ }
90
81
  return;
91
82
  }
92
83
  let idx = 0;
93
- setSpinnerChar(SPINNER_FRAMES[0]);
94
84
  spinnerRef.current = setInterval(function () {
95
- idx = (idx + 1) % SPINNER_FRAMES.length;
96
- setSpinnerChar(SPINNER_FRAMES[idx]);
97
- }, 120);
98
- return function cleanup() {
85
+ idx = (idx + 1) % SPINNER.length;
86
+ setSpinnerIdx(idx);
87
+ }, 100);
88
+ timerRef.current = setInterval(function () {
89
+ if (thinkStartRef.current !== null) {
90
+ setThinkingElapsed((Date.now() - thinkStartRef.current) / 1000);
91
+ }
92
+ }, 100);
93
+ return function () {
99
94
  if (spinnerRef.current !== null) {
100
95
  clearInterval(spinnerRef.current);
101
96
  spinnerRef.current = null;
102
97
  }
98
+ if (timerRef.current !== null) {
99
+ clearInterval(timerRef.current);
100
+ timerRef.current = null;
101
+ }
103
102
  };
104
103
  }, [isThinking]);
105
104
 
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
- }
105
+ function cycleModel() {
106
+ const models = getModels();
107
+ setActiveModel(function (prev) {
108
+ let found = false;
109
+ for (let i = 0; i < models.length; i++) {
110
+ if (found) return models[i].id;
111
+ if (models[i].id === prev) found = true;
112
+ }
113
+ return models[0].id;
114
+ });
122
115
  }
123
116
 
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);
117
+ function checkThink(text) {
118
+ const o = text.lastIndexOf('<think>');
119
+ const c = text.lastIndexOf('</think>');
120
+ if (o === -1 && c === -1) return false;
121
+ return o > c;
178
122
  }
179
123
 
180
124
  async function handleSubmit(text) {
181
- 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);
125
+ const safe = typeof text === 'string' ? text : '';
126
+ if (safe.length === 0 || streamingActive === true) return;
187
127
 
188
128
  setStreamingActive(true);
189
- setScrollOffset(0);
190
- setMessages(function (prev) {
191
- return prev.concat([{ role: 'user', content: safeText, id: msgId }]);
192
- });
193
- setInputBuffer('');
129
+ setToolInfo(null);
194
130
  setStreamText(' connecting');
195
- connectingRef.current = setInterval(function () {
196
- setStreamText(function (prev) {
197
- if (prev === ' connecting') return ' connecting.';
198
- if (prev === ' connecting.') return ' connecting..';
199
- if (prev === ' connecting..') return ' connecting...';
200
- if (prev === ' connecting...') return ' connecting';
201
- return prev;
131
+ setThinkingElapsed(0);
132
+
133
+ connRef.current = setInterval(function () {
134
+ setStreamText(function (p) {
135
+ if (p === ' connecting') return ' connecting.';
136
+ if (p === ' connecting.') return ' connecting..';
137
+ if (p === ' connecting..') return ' connecting...';
138
+ return ' connecting';
202
139
  });
203
140
  }, 400);
204
141
 
142
+ setMessages(function (p) {
143
+ return p.concat([{ role: 'user', content: safe, id: 'u-' + Date.now() }]);
144
+ });
145
+ setInputBuffer('');
146
+
205
147
  const modelInfo = getModelById(activeModel);
206
- if (modelInfo === null || modelInfo === undefined) {
207
- if (connectingRef.current !== null) {
208
- clearInterval(connectingRef.current);
209
- connectingRef.current = null;
148
+ if (modelInfo === null) {
149
+ if (connRef.current !== null) {
150
+ clearInterval(connRef.current);
151
+ connRef.current = null;
210
152
  }
211
153
  setStreamingActive(false);
212
154
  setStreamText('');
213
- setScrollOffset(0);
214
- setMessages(function (prev) {
215
- return prev.concat([{
216
- role: 'assistant',
217
- content: 'No model selected. Use `/model` to switch.',
218
- id: 'err-' + Date.now(),
219
- }]);
155
+ setMessages(function (p) {
156
+ return p.concat([{ role: 'assistant', content: 'No model selected. Use /model.', id: 'e-' + Date.now() }]);
220
157
  });
221
158
  return;
222
159
  }
223
160
 
224
- const history = buildHistory(messages, safeText);
161
+ const history = buildHistory(messages, safe);
225
162
  const abortController = new AbortController();
226
163
  abortRef.current = abortController;
227
164
 
228
165
  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
166
  const gen = callModel(modelInfo, history, abortController.signal);
242
167
  let accumulated = '';
243
168
  let firstEvent = true;
169
+ let thinkStarted = false;
244
170
 
245
171
  for await (const event of gen) {
246
- if (abortController.signal.aborted === true) {
247
- break;
248
- }
172
+ if (abortController.signal.aborted === true) break;
173
+
249
174
  if (firstEvent === true) {
250
175
  firstEvent = false;
251
- if (connectingRef.current !== null) {
252
- clearInterval(connectingRef.current);
253
- connectingRef.current = null;
176
+ if (connRef.current !== null) {
177
+ clearInterval(connRef.current);
178
+ connRef.current = null;
254
179
  }
255
180
  }
181
+
182
+ if (event.type === 'tool') {
183
+ setToolInfo({ tool: event.tool, used: event.used, sites: event.sites, query: event.query });
184
+ continue;
185
+ }
186
+
256
187
  if (event.type === 'thinking' || event.type === 'token') {
257
188
  const content = typeof event.content === 'string' ? event.content : '';
258
189
  for (let i = 0; i < content.length; i++) {
259
190
  accumulated = accumulated + content[i];
260
- const isInsideThink = checkThinkState(accumulated);
261
- setScrollOffset(0);
191
+ const inside = checkThink(accumulated);
262
192
  setStreamText(accumulated);
263
- setIsThinking(isInsideThink);
264
- if (isInsideThink === true) {
265
- thinkingAccumRef.current = accumulated;
193
+ setIsThinking(inside);
194
+ if (inside === true && thinkStarted === false) {
195
+ thinkStarted = true;
196
+ thinkStartRef.current = Date.now();
197
+ setThinkingElapsed(0);
198
+ }
199
+ if (inside === false && thinkStarted === true) {
200
+ thinkStarted = false;
201
+ thinkStartRef.current = null;
202
+ setThinkingElapsed(0);
266
203
  }
267
- await new Promise(function (resolve) {
268
- setTimeout(resolve, 0);
269
- });
204
+ await new Promise(function (r) { setTimeout(r, 0); });
270
205
  }
271
206
  }
272
- if (event.type === 'done') {
273
- break;
274
- }
207
+
208
+ if (event.type === 'done') break;
275
209
  if (event.type === 'timeout') {
276
210
  if (accumulated.length > 0) {
277
- accumulated = accumulated + '\n\n*[Response incomplete \u2014 stream timed out]*';
211
+ accumulated = accumulated + '\n\n\u2014 stream timed out \u2014';
278
212
  }
279
213
  setStreamText(accumulated);
280
214
  break;
281
215
  }
282
216
  }
283
217
 
284
- if (connectingRef.current !== null) {
285
- clearInterval(connectingRef.current);
286
- connectingRef.current = null;
218
+ if (connRef.current !== null) {
219
+ clearInterval(connRef.current);
220
+ connRef.current = null;
287
221
  }
288
222
  setIsThinking(false);
289
- thinkingAccumRef.current = '';
223
+ thinkStartRef.current = null;
224
+ setThinkingElapsed(0);
290
225
  setStreamingActive(false);
226
+
227
+ const finalText = streamText;
291
228
  setStreamText('');
292
229
 
293
- if (accumulated.length > 0) {
294
- setMessages(function (prev) {
295
- return prev.concat([{
296
- role: 'assistant',
297
- content: accumulated,
298
- id: 'resp-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8),
299
- }]);
230
+ if (finalText.length > 0) {
231
+ setMessages(function (p) {
232
+ return p.concat([{ role: 'assistant', content: finalText, id: 'a-' + Date.now() }]);
300
233
  });
301
234
  }
302
235
  } catch (err) {
303
- if (connectingRef.current !== null) {
304
- clearInterval(connectingRef.current);
305
- connectingRef.current = null;
236
+ if (connRef.current !== null) {
237
+ clearInterval(connRef.current);
238
+ connRef.current = null;
306
239
  }
307
240
  setIsThinking(false);
308
- thinkingAccumRef.current = '';
241
+ thinkStartRef.current = null;
242
+ setThinkingElapsed(0);
309
243
  setStreamingActive(false);
310
244
  setStreamText('');
311
- setScrollOffset(0);
312
- const errMsg = (err !== null && err !== undefined)
245
+ const errMsg = err !== null && err !== undefined
313
246
  ? (err.message || String(err))
314
247
  : 'Unknown error';
315
- setMessages(function (prev) {
316
- return prev.concat([{
248
+ setMessages(function (p) {
249
+ return p.concat([{
317
250
  role: 'assistant',
318
- 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),
251
+ content: 'Error: ' + errMsg + '\n\nSpace cold-starting? Try again.',
252
+ id: 'er-' + Date.now(),
322
253
  }]);
323
254
  });
324
255
  }
325
256
  }
326
257
 
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
- useInput(function handleInput(input, key) {
258
+ function handleChange(v) {
259
+ const s = typeof v === 'string' ? v : '';
260
+ setInputBuffer(s);
261
+ if (s.length === 1 && s[0] === '/' && showOverlay === false) {
262
+ setShowOverlay(true);
263
+ setOverlayIndex(0);
264
+ }
265
+ if (showOverlay === true && (s.length === 0 || s[0] !== '/')) {
266
+ setShowOverlay(false);
267
+ }
268
+ }
336
269
 
337
- // ── Overlay branch ──────────────────────────────────────────────────────
270
+ function handleCmd(item) {
271
+ if (item === null || item === undefined) return;
272
+ const t = item.value;
273
+ if (typeof t !== 'string') return;
274
+ if (t === '/exit') process.exit(0);
275
+ if (t === '/clear') setMessages([]);
276
+ if (t === '/model') cycleModel();
277
+ if (t === '/help') {
278
+ setMessages(function (p) {
279
+ return p.concat([{ role: 'assistant', content: 'Commands: /help /clear /model /exit', id: 'h-' + Date.now() }]);
280
+ });
281
+ }
282
+ setInputBuffer('');
283
+ setShowOverlay(false);
284
+ setOverlayIndex(0);
285
+ }
286
+
287
+ useInput(function handleInput(input, key) {
338
288
  if (showOverlay === true) {
339
289
  if (key.escape === true) {
340
290
  setShowOverlay(false);
@@ -342,63 +292,31 @@ function AppRoot() {
342
292
  setOverlayIndex(0);
343
293
  return;
344
294
  }
345
-
346
295
  if (key.upArrow === true) {
347
- setOverlayIndex(function (prev) {
348
- return prev > 0 ? prev - 1 : commands.length - 1; // wrap around top
349
- });
296
+ setOverlayIndex(function (p) { return p > 0 ? p - 1 : commands.length - 1; });
350
297
  return;
351
298
  }
352
-
353
299
  if (key.downArrow === true) {
354
- setOverlayIndex(function (prev) {
355
- return prev < commands.length - 1 ? prev + 1 : 0; // wrap around bottom
356
- });
300
+ setOverlayIndex(function (p) { return p < commands.length - 1 ? p + 1 : 0; });
357
301
  return;
358
302
  }
359
-
360
303
  if (key.return === true) {
361
- const selectedCmd = commands[overlayIndex];
362
- if (selectedCmd !== null && selectedCmd !== undefined) {
363
- handleSlashSelect({ value: selectedCmd.trigger });
364
- }
304
+ const cmd = commands[overlayIndex];
305
+ if (cmd !== null && cmd !== undefined) handleCmd({ value: cmd.trigger });
365
306
  return;
366
307
  }
367
-
368
- // Absorb everything else while overlay is open
369
308
  return;
370
309
  }
371
310
 
372
- // ── Normal branch ────────────────────────────────────────────────────────
373
311
  if (key.return === true) {
374
- const safeValue = typeof inputBuffer === 'string' ? inputBuffer : '';
375
- if (safeValue.length > 0 && streamingActive === false) {
376
- handleSubmit(safeValue);
377
- }
378
- return;
379
- }
380
-
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
- });
312
+ const s = typeof inputBuffer === 'string' ? inputBuffer : '';
313
+ if (s.length > 0 && streamingActive === false) handleSubmit(s);
394
314
  return;
395
315
  }
396
316
 
397
317
  if (key.ctrl === true && (input === 'c' || input === 'C')) {
398
318
  if (abortRef.current !== null) {
399
- try {
400
- abortRef.current.abort();
401
- } catch (_) { }
319
+ try { abortRef.current.abort(); } catch (_) {}
402
320
  abortRef.current = null;
403
321
  setStreamingActive(false);
404
322
  setStreamText('');
@@ -407,27 +325,116 @@ function AppRoot() {
407
325
  }
408
326
  });
409
327
 
410
- // ---------------------------------------------------------------------------
411
- // Render
412
- // ---------------------------------------------------------------------------
328
+ const activeInfo = getModelById(activeModel);
329
+ const activeLabel = activeInfo !== null ? activeInfo.label : 'OpenAxies';
330
+ const activeBadge = activeInfo !== null ? activeInfo.badge : '';
331
+
332
+ const viewLines = [];
333
+ for (let i = 0; i < messages.length; i++) {
334
+ const msg = messages[i];
335
+ if (msg === null || typeof msg !== 'object') continue;
336
+ const content = typeof msg.content === 'string' ? msg.content : '';
337
+ if (content.length === 0) continue;
338
+
339
+ if (msg.role === 'user') {
340
+ if (i > 0) viewLines.push({ t: 'sep' });
341
+ viewLines.push({ t: 'user', text: content });
342
+ viewLines.push({ t: 'sep' });
343
+ } else {
344
+ viewLines.push({ t: 'text', text: content });
345
+ }
346
+ }
347
+
348
+ const hasStream = typeof streamText === 'string' && streamText.length > 0;
349
+ if (hasStream === true) {
350
+ viewLines.push({ t: 'sep' });
351
+ if (toolInfo !== null && toolInfo.used === true && toolInfo.query.length > 0) {
352
+ viewLines.push({ t: 'tool', text: '\u2500\u2500 ' + toolInfo.tool + ': "' + toolInfo.query + '" (' + toolInfo.sites + ' sites)' });
353
+ }
354
+ if (isThinking === true) {
355
+ viewLines.push({ t: 'think', spin: SPINNER[spinnerIdx], elapsed: formatTimer(thinkingElapsed) });
356
+ const clean = streamText.split('<think>').join('').split('</think>').join('');
357
+ viewLines.push({ t: 'think-text', text: clean });
358
+ } else {
359
+ const clean = streamText.split('<think>').join('').split('</think>').join('');
360
+ viewLines.push({ t: 'text', text: clean });
361
+ }
362
+ }
363
+
364
+ if (viewLines.length === 0) {
365
+ viewLines.push({ t: 'idle', text: ' start typing to chat...' });
366
+ }
367
+
368
+ const visible = Math.max(1, availLines - 1);
369
+ const display = viewLines.slice(-visible);
370
+ const viewEls = [];
371
+
372
+ for (let i = 0; i < display.length; i++) {
373
+ const l = display[i];
374
+ if (l.t === 'sep') {
375
+ viewEls.push(h(Box, { key: 's-' + i, height: 1 }, hr(cols)));
376
+ } else if (l.t === 'think') {
377
+ viewEls.push(
378
+ h(Box, { key: 'k-' + i, height: 1, paddingLeft: 1 },
379
+ h(Text, { color: '#FF9F43', bold: true }, ' ' + l.spin + ' Thinking \u2022 ' + l.elapsed)
380
+ )
381
+ );
382
+ } else if (l.t === 'think-text') {
383
+ viewEls.push(
384
+ h(Box, { key: 'kt-' + i, height: 1, paddingLeft: 2 },
385
+ h(Text, { color: '#FF9F43' }, l.text)
386
+ )
387
+ );
388
+ } else if (l.t === 'tool') {
389
+ viewEls.push(
390
+ h(Box, { key: 'tl-' + i, height: 1, paddingLeft: 1 },
391
+ h(Text, { color: '#5B5B8A' }, l.text)
392
+ )
393
+ );
394
+ } else if (l.t === 'user') {
395
+ viewEls.push(
396
+ h(Box, { key: 'u-' + i, height: 1, paddingLeft: 1 },
397
+ h(Text, { color: hex.neonBlue }, l.text)
398
+ )
399
+ );
400
+ } else if (l.t === 'text') {
401
+ viewEls.push(
402
+ h(Box, { key: 'a-' + i, height: 1, paddingLeft: 1 },
403
+ h(Text, { color: hex.primary }, l.text)
404
+ )
405
+ );
406
+ } else if (l.t === 'idle') {
407
+ viewEls.push(
408
+ h(Box, { key: 'i-' + i, height: 1, paddingLeft: 1 },
409
+ h(Text, { color: '#333355' }, l.text)
410
+ )
411
+ );
412
+ }
413
+ }
414
+
415
+ const viewport = h(Box, {
416
+ flexGrow: 1,
417
+ height: availLines,
418
+ flexDirection: 'column',
419
+ overflow: 'hidden',
420
+ paddingBottom: 1,
421
+ }, ...viewEls);
413
422
 
414
- const header = createBrandHeader(cols);
415
- const routerBar = createRouterBar(activeModel, cols);
416
- const viewport = createChatViewport(messages, streamText, availLines, cols, scrollOffset, isThinking, spinnerChar);
417
423
  const slashOverlay = showOverlay === true
418
424
  ? createSlashOverlay(commands, overlayIndex)
419
425
  : null;
426
+
420
427
  const dock = createComposerDock({
421
428
  value: inputBuffer,
422
- onChange: handleInputChange,
423
- onSubmit: handleInputSubmit,
424
- inputActive: showOverlay === false, // yield focus to overlay navigation
429
+ onChange: handleChange,
430
+ onSubmit: function () {},
431
+ inputActive: showOverlay === false,
425
432
  isStreaming: streamingActive,
426
433
  terminalWidth: cols,
434
+ modelLabel: activeLabel + ' \u25CF',
435
+ modelStatus: true,
427
436
  });
428
437
 
429
- // Strict column layout — total height === rows (no scroll, no overflow).
430
- // ComposerDock has flexShrink:0 so it is always pinned at bottom.
431
438
  return h(Box, {
432
439
  flexDirection: 'column',
433
440
  width: '100%',
@@ -435,10 +442,11 @@ function AppRoot() {
435
442
  backgroundColor: hex.black,
436
443
  overflow: 'hidden',
437
444
  },
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
445
+ h(Box, { height: 1 }, hr(cols)),
446
+ viewport,
447
+ slashOverlay,
448
+ dock
443
449
  );
444
450
  }
451
+
452
+ import { createSlashOverlay } from './components/SlashOverlay.js';