clarity-ai 4.3.0 → 5.0.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "clarity-ai",
3
- "version": "4.3.0",
4
- "description": "Premium terminal AI agent for Termux OpenCode-style UI, streaming, markdown, tools, agent mode",
3
+ "version": "5.0.0",
4
+ "description": "Premium OpenCode-style terminal AI agent — streaming, tools, multiline composer, virtual scroll, code blocks",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "clarity": "bin/clarity.js"
package/src/app.js CHANGED
@@ -1,23 +1,23 @@
1
- import React, { useState, useCallback } from 'react';
1
+ import React, { useState, useCallback, useRef, useEffect } from 'react';
2
2
  import { Box } from 'ink';
3
3
  import { Banner } from './components/Banner.js';
4
4
  import { MessageList } from './components/MessageList.js';
5
- import { PromptBox } from './components/PromptBox.js';
5
+ import { Composer } from './components/Composer.js';
6
6
  import { CommandPicker } from './components/CommandPicker.js';
7
7
  import { ModelPicker } from './components/ModelPicker.js';
8
- import { useScroll } from './hooks/useScroll.js';
9
8
  import { createChatState, handleSend, handleCommand } from './chat.js';
10
9
  const { createElement: h } = React;
11
10
 
12
11
  export function App({ config }) {
13
- const [state, setState] = useState(createChatState);
12
+ const [state, setState] = useState(() => createChatState());
13
+ const [streamContent, setStreamContent] = useState('');
14
14
  const defaultModel = (config.model || 'groq/llama-3.3-70b-versatile').replace(/^[^/]+\//, '');
15
15
  const [model, setModel] = useState(defaultModel);
16
16
  const [provider, setProvider] = useState(config.provider || 'groq');
17
17
  const [showCommands, setShowCommands] = useState(false);
18
18
  const [showModels, setShowModels] = useState(false);
19
19
  const [showBanner, setShowBanner] = useState(true);
20
- const { scrollOffset, termHeight } = useScroll(state.messages.length);
20
+ const streamRef = useRef('');
21
21
 
22
22
  const onSubmit = useCallback(async (input) => {
23
23
  if (input.startsWith('/')) {
@@ -27,12 +27,12 @@ export function App({ config }) {
27
27
  return;
28
28
  }
29
29
  if (showBanner) setShowBanner(false);
30
- await handleSend(state, setState, input, model, provider);
31
- }, [state, state.messages, model, provider, showBanner]);
30
+ await handleSend(state, setState, input, model, provider, setStreamContent);
31
+ }, [state, model, provider, showBanner]);
32
32
 
33
- function handleCommandSelect(cmd) {
33
+ function handleCommandSelect(cmdName) {
34
34
  setShowCommands(false);
35
- onSubmit(cmd);
35
+ onSubmit(cmdName);
36
36
  }
37
37
 
38
38
  function handleModelSelect(modelId) {
@@ -52,20 +52,31 @@ export function App({ config }) {
52
52
  h(MessageList, {
53
53
  messages: state.messages,
54
54
  thinking: state.thinking,
55
- scrollOffset,
56
- termHeight,
55
+ streamContent,
57
56
  })
58
57
  ),
59
- showCommands ? h(CommandPicker, {
60
- query: '',
61
- onSelect: handleCommandSelect,
62
- onClose: () => setShowCommands(false),
63
- }) : null,
64
- showModels ? h(ModelPicker, {
65
- onSelect: handleModelSelect,
66
- onClose: () => setShowModels(false),
67
- }) : null,
68
- h(PromptBox, {
58
+ showCommands
59
+ ? h(Box, { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, justifyContent: 'center', alignItems: 'center' },
60
+ h(Box, { backgroundColor: '#0A0A0A', borderStyle: 'round', borderColor: '#333', paddingX: 2, paddingY: 1 },
61
+ h(CommandPicker, {
62
+ query: '',
63
+ onSelect: handleCommandSelect,
64
+ onClose: () => setShowCommands(false),
65
+ })
66
+ )
67
+ )
68
+ : null,
69
+ showModels
70
+ ? h(Box, { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, justifyContent: 'center', alignItems: 'center' },
71
+ h(Box, { backgroundColor: '#0A0A0A', borderStyle: 'round', borderColor: '#333', paddingX: 2, paddingY: 1 },
72
+ h(ModelPicker, {
73
+ onSelect: handleModelSelect,
74
+ onClose: () => setShowModels(false),
75
+ })
76
+ )
77
+ )
78
+ : null,
79
+ h(Composer, {
69
80
  provider,
70
81
  model,
71
82
  agentMode: state.agentMode,
package/src/chat.js CHANGED
@@ -1,13 +1,13 @@
1
1
  import { callAI } from './providers/index.js';
2
2
  import { setKey } from './config/keys.js';
3
3
  import { TOOLS, executeTool } from './tools.js';
4
- import { extractCommandFromText } from './intentDetect.js';
4
+ const sleep = ms => new Promise(r => setTimeout(r, ms));
5
5
 
6
6
  export function createChatState() {
7
7
  return {
8
8
  messages: [],
9
9
  thinking: false,
10
- streamBuffer: '',
10
+ streamContent: '',
11
11
  awaitingKey: false,
12
12
  blockedProvider: null,
13
13
  agentMode: true,
@@ -19,123 +19,143 @@ export function createChatState() {
19
19
  let msgId = 0;
20
20
  function nextId() { return 'm' + (++msgId); }
21
21
 
22
- export async function handleSend(state, setState, input, model, provider) {
22
+ export async function handleSend(state, setState, input, model, provider, onStreamContent) {
23
23
  if (!input.trim() || state.awaitingKey) return;
24
24
 
25
25
  const userMsg = { id: nextId(), role: 'user', content: input };
26
- setState(s => ({ ...s, messages: [...s.messages, userMsg], thinking: true, streamBuffer: '' }));
26
+ setState(s => ({
27
+ ...s,
28
+ messages: [...s.messages, userMsg],
29
+ thinking: true,
30
+ streamContent: '',
31
+ }));
32
+ onStreamContent('');
27
33
 
28
34
  try {
29
- const history = [...state.messages, userMsg].map(m => ({
30
- role: m.role === 'error' ? 'assistant' : m.role,
31
- content: m.content,
32
- }));
35
+ const history = [...state.messages, userMsg].map(m => {
36
+ const base = {
37
+ role: m.role === 'error' ? 'assistant' : m.role,
38
+ content: m.content,
39
+ };
40
+ if (m.role === 'tool' && m.tool_call_id) {
41
+ base.tool_call_id = m.tool_call_id;
42
+ }
43
+ if (m.role === 'assistant' && m.toolCalls) {
44
+ base.tool_calls = m.toolCalls.map(tc => ({
45
+ id: tc.id, type: 'function',
46
+ function: { name: tc.function.name, arguments: tc.function.arguments },
47
+ }));
48
+ }
49
+ return base;
50
+ });
33
51
 
34
- await processStream(provider, model, history, state.agentMode, setState);
52
+ await processStream(provider, model, history, state.agentMode, setState, onStreamContent);
35
53
  } catch (err) {
36
54
  if (err.type) {
37
55
  handleError(setState, err);
38
56
  } else {
39
57
  setState(s => ({
40
- ...s, thinking: false,
58
+ ...s, thinking: false, streamContent: '',
41
59
  messages: [...s.messages, { id: nextId(), role: 'error', content: err.message || 'Unknown error' }],
42
60
  }));
43
61
  }
62
+ onStreamContent('');
44
63
  }
45
64
  }
46
65
 
47
- async function processStream(provider, model, history, agentMode, setState, depth = 0) {
66
+ async function processStream(provider, model, history, agentMode, setState, onStreamContent, depth = 0) {
48
67
  if (depth > 10) {
49
- setState(s => ({ ...s, thinking: false, streamBuffer: '' }));
68
+ setState(s => ({ ...s, thinking: false, streamContent: '' }));
69
+ onStreamContent('');
50
70
  return;
51
71
  }
52
72
 
53
- const stream = callAI(provider, model, history, { tools: agentMode ? TOOLS : undefined });
73
+ let stream;
74
+ try {
75
+ stream = callAI(provider, model, history, { tools: agentMode ? TOOLS : undefined });
76
+ } catch (err) {
77
+ if (err.type === 'rate_limit') { await sleep(2000); return processStream(provider, model, history, agentMode, setState, onStreamContent, depth); }
78
+ throw err;
79
+ }
80
+
54
81
  let buffer = '';
55
82
  let toolCalls = null;
56
83
 
57
- for await (const event of stream) {
58
- if (event.type === 'token') {
59
- buffer += event.content;
60
- setState(s => ({
61
- ...s,
62
- streamBuffer: buffer,
63
- messages: updateLastAssistant(s.messages, buffer),
64
- }));
65
- } else if (event.type === 'tool_calls') {
66
- toolCalls = event.calls;
67
- } else if (event.type === 'error') {
68
- handleError(setState, event);
69
- return;
84
+ try {
85
+ for await (const event of stream) {
86
+ if (event.type === 'token') {
87
+ buffer += event.content;
88
+ onStreamContent(buffer);
89
+ } else if (event.type === 'tool_calls') {
90
+ toolCalls = event.calls;
91
+ } else if (event.type === 'error') {
92
+ handleError(setState, event);
93
+ onStreamContent('');
94
+ return;
95
+ }
70
96
  }
97
+ } catch (err) {
98
+ if (err.type === 'rate_limit') { await sleep(2000); return processStream(provider, model, history, agentMode, setState, onStreamContent, depth); }
99
+ throw err;
100
+ }
101
+
102
+ if (buffer) {
103
+ setState(s => ({
104
+ ...s,
105
+ messages: [...s.messages, { id: nextId(), role: 'assistant', content: buffer, streaming: false }],
106
+ streamContent: '',
107
+ }));
108
+ onStreamContent('');
109
+ } else {
110
+ setState(s => ({ ...s, streamContent: '' }));
111
+ onStreamContent('');
71
112
  }
72
113
 
73
114
  if (toolCalls && toolCalls.length > 0 && agentMode) {
74
- const assistantMsg = { id: nextId(), role: 'assistant', content: buffer || '', toolCalls };
75
115
  const toolResults = [];
76
-
77
116
  for (const tc of toolCalls) {
78
117
  const { name, arguments: argsStr } = tc.function;
79
118
  let args;
80
- try {
81
- args = JSON.parse(argsStr);
82
- } catch {
83
- args = {};
84
- }
85
-
86
- setState(s => ({
87
- ...s,
88
- messages: [...s.messages, { id: nextId(), role: 'tool', content: '⚙ ' + name + '(' + JSON.stringify(args).slice(0, 100) + ')...', toolName: name }],
89
- }));
90
-
119
+ try { args = JSON.parse(argsStr); } catch { args = {}; }
91
120
  const result = await executeTool(name, args);
92
- toolResults.push({ tool_call_id: tc.id, role: 'tool', content: result });
121
+ toolResults.push({ tool_call_id: tc.id, role: 'tool', content: result, name });
93
122
  }
94
123
 
124
+ setState(s => ({
125
+ ...s,
126
+ messages: [
127
+ ...s.messages,
128
+ { id: nextId(), role: 'assistant', content: buffer || '', toolCalls, streaming: false },
129
+ ...toolResults.map(tr => ({
130
+ id: nextId(), role: 'tool', content: tr.content,
131
+ tool_call_id: tr.tool_call_id, toolName: tr.name,
132
+ })),
133
+ ],
134
+ }));
135
+
95
136
  const newHistory = [
96
137
  ...history,
97
138
  { role: 'assistant', content: buffer || null, tool_calls: toolCalls.map(tc => ({
98
- id: tc.id,
99
- type: 'function',
139
+ id: tc.id, type: 'function',
100
140
  function: { name: tc.function.name, arguments: tc.function.arguments },
101
141
  }))},
102
142
  ...toolResults,
103
143
  ];
104
144
 
105
- await processStream(provider, model, newHistory, agentMode, setState, depth + 1);
106
- } else {
107
- setState(s => ({ ...s, thinking: false, streamBuffer: '' }));
145
+ await processStream(provider, model, newHistory, agentMode, setState, onStreamContent, depth + 1);
108
146
  }
109
147
  }
110
148
 
111
149
  function handleError(setState, err) {
112
- if (err.type === 'auth_error') {
113
- setState(s => ({
114
- ...s, thinking: false, awaitingKey: true, blockedProvider: err.provider,
115
- messages: [...s.messages, { id: nextId(), role: 'error', content: err.hint || err.message }],
116
- }));
117
- } else if (err.type === 'rate_limit') {
118
- setState(s => ({
119
- ...s, thinking: false,
120
- messages: [...s.messages, { id: nextId(), role: 'error', content: err.message }],
121
- }));
122
- } else {
123
- setState(s => ({
124
- ...s, thinking: false,
125
- messages: [...s.messages, { id: nextId(), role: 'error', content: err.message }],
126
- }));
127
- }
128
- }
129
-
130
- function updateLastAssistant(messages, buffer) {
131
- const copy = [...messages];
132
- const last = copy[copy.length - 1];
133
- if (last && last.role === 'assistant') {
134
- copy[copy.length - 1] = { ...last, content: buffer, streaming: true };
135
- } else {
136
- copy.push({ id: nextId(), role: 'assistant', content: buffer, streaming: true });
137
- }
138
- return copy;
150
+ setState(s => ({
151
+ ...s, thinking: false, streamContent: '',
152
+ messages: [...s.messages, {
153
+ id: nextId(),
154
+ role: 'error',
155
+ content: err.hint || err.message,
156
+ type: err.type,
157
+ }],
158
+ }));
139
159
  }
140
160
 
141
161
  export async function handleCommand(input, state, setState, modelSetter, providerSetter, model, provider) {
@@ -165,7 +185,7 @@ export async function handleCommand(input, state, setState, modelSetter, provide
165
185
  }));
166
186
  break;
167
187
  case '/clear':
168
- setState(s => ({ ...s, messages: [], streamBuffer: '' }));
188
+ setState(s => ({ ...s, messages: [], streamContent: '' }));
169
189
  break;
170
190
  case '/theme':
171
191
  setState(s => ({
@@ -176,11 +196,11 @@ export async function handleCommand(input, state, setState, modelSetter, provide
176
196
  case '/export': {
177
197
  const text = state.messages.map(m => '[' + m.role + '] ' + m.content).join('\n\n');
178
198
  const { writeFileSync } = await import('fs');
179
- const path = 'clarity-export-' + Date.now() + '.md';
180
- writeFileSync(path, text);
199
+ const fpath = 'clarity-export-' + Date.now() + '.md';
200
+ writeFileSync(fpath, text);
181
201
  setState(s => ({
182
202
  ...s,
183
- messages: [...s.messages, { id: nextId(), role: 'system', content: 'Exported to ' + path }],
203
+ messages: [...s.messages, { id: nextId(), role: 'system', content: 'Exported to ' + fpath }],
184
204
  }));
185
205
  break;
186
206
  }
@@ -1,13 +1,40 @@
1
- import React from 'react';
1
+ import React, { useMemo } from 'react';
2
2
  import { Box, Text } from 'ink';
3
- import highlight from 'cli-highlight';
4
3
  const { createElement: h } = React;
5
4
 
6
- export function CodeBlock({ code, lang }) {
7
- const highlighted = lang ? highlight(code, { language: lang, ignoreIllegals: true }) : code;
5
+ const LANG_COLORS = {
6
+ js: '#F0DB4F', jsx: '#F0DB4F', ts: '#3178C6', tsx: '#3178C6',
7
+ py: '#3572A5', rb: '#CC342D', go: '#00ADD8', rs: '#DEA584',
8
+ java: '#B07219', kt: '#7F52FF', swift: '#FFAC45',
9
+ html: '#E34F26', css: '#1572B6', scss: '#CC6699',
10
+ sh: '#89E051', bash: '#89E051', dockerfile: '#384D54',
11
+ json: '#292929', yaml: '#CB171E', md: '#083FA1', sql: '#E38C00',
12
+ };
8
13
 
9
- return h(Box, { flexDirection: 'column', paddingLeft: 2, marginY: 1 },
10
- lang ? h(Text, { color: 'gray', dimColor: true }, lang) : null,
11
- h(Text, null, highlighted)
14
+ export function CodeBlock({ code, language, termWidth }) {
15
+ const lang = language || 'code';
16
+ const lines = useMemo(() => String(code).split('\n'), [code]);
17
+ const langColor = LANG_COLORS[lang] || '#555';
18
+ const lineNumWidth = String(lines.length).length;
19
+
20
+ return h(Box, { flexDirection: 'column', marginY: 1, marginLeft: 2 },
21
+ h(Box, { flexDirection: 'row' },
22
+ h(Box, { backgroundColor: '#1C1C1C', paddingX: 1 },
23
+ h(Text, { color: langColor, bold: true }, ' ' + lang + ' '),
24
+ h(Text, { color: '#555' }, String(lines.length).padStart(3) + ' lines '),
25
+ )
26
+ ),
27
+ h(Box, { flexDirection: 'column', backgroundColor: '#0D1117', paddingY: 0 },
28
+ lines.map((line, i) =>
29
+ h(Box, { key: i, flexDirection: 'row' },
30
+ h(Text, { color: '#555', backgroundColor: '#0D1117' },
31
+ ' ' + String(i + 1).padStart(lineNumWidth) + ' '
32
+ ),
33
+ h(Text, { color: '#C9D1D9', backgroundColor: '#0D1117', wrap: 'truncate-end' },
34
+ line || ' '
35
+ )
36
+ )
37
+ )
38
+ )
12
39
  );
13
40
  }
@@ -23,21 +23,24 @@ export function CommandPicker({ query, onSelect, onClose }) {
23
23
  useInput((input, key) => {
24
24
  if (key.upArrow) setIdx(i => Math.max(0, i - 1));
25
25
  if (key.downArrow) setIdx(i => Math.min(filtered.length - 1, i + 1));
26
- if (key.return) onSelect(filtered[idx]);
26
+ if (key.return) onSelect(filtered[idx].name);
27
27
  if (key.escape) onClose();
28
28
  });
29
29
 
30
- return h(Box, { flexDirection: 'column', paddingX: 1 },
31
- h(Text, { color: '#00D4FF', bold: true }, 'Commands'),
32
- h(Text, { color: '#333333' }, '\u2500'.repeat(30)),
30
+ return h(Box, { flexDirection: 'column', paddingX: 1, borderStyle: 'round', borderColor: '#333' },
31
+ h(Text, { color: '#00D4FF', bold: true }, ' Commands'),
32
+ h(Text, { color: '#333' }, ''),
33
33
  filtered.map((cmd, i) =>
34
34
  h(Box, { key: cmd.name, flexDirection: 'row', gap: 2 },
35
35
  h(Text, {
36
36
  color: i === idx ? '#FF6B6B' : '#F0F0F0',
37
37
  bold: i === idx,
38
- }, '\u25B6 ' + cmd.name),
39
- h(Text, { color: '#555555' }, cmd.desc)
38
+ backgroundColor: i === idx ? '#2A2A2A' : undefined,
39
+ }, ' ' + cmd.name),
40
+ h(Text, { color: '#555' }, cmd.desc)
40
41
  )
41
- )
42
+ ),
43
+ h(Text, { color: '#333' }, ''),
44
+ h(Text, { color: '#555' }, ' \u2191\u2193 navigate \u23CE select Esc close')
42
45
  );
43
46
  }
@@ -0,0 +1,152 @@
1
+ import React, { useState, useRef, useCallback } from 'react';
2
+ import { Box, Text, useInput } from 'ink';
3
+ const { createElement: h } = React;
4
+ const w = () => process.stdout.columns || 80;
5
+
6
+ export function Composer({ provider, model, agentMode, thinking, onSlash, onSubmit }) {
7
+ const [lines, setLines] = useState(['']);
8
+ const [cursorLine, setCursorLine] = useState(0);
9
+ const [cursorCol, setCursorCol] = useState(0);
10
+ const inputRef = useRef({ lines: [''], line: 0, col: 0 });
11
+ const displayName = provider + '/' + model;
12
+
13
+ useInput((input, key) => {
14
+ if (thinking) return;
15
+ const state = inputRef.current;
16
+
17
+ if (key.return && !key.shift) {
18
+ const text = state.lines.join('\n');
19
+ setLines(['']);
20
+ state.lines = [''];
21
+ state.line = 0;
22
+ state.col = 0;
23
+ setCursorLine(0);
24
+ setCursorCol(0);
25
+ if (text.trim()) onSubmit(text);
26
+ return;
27
+ }
28
+
29
+ if (key.return && key.shift) {
30
+ state.lines.splice(state.line + 1, 0,
31
+ state.lines[state.line].slice(state.col)
32
+ );
33
+ state.lines[state.line] = state.lines[state.line].slice(0, state.col);
34
+ state.line++;
35
+ state.col = 0;
36
+ setLines([...state.lines]);
37
+ setCursorLine(state.line);
38
+ setCursorCol(state.col);
39
+ return;
40
+ }
41
+
42
+ if (key.backspace || key.delete) {
43
+ if (state.col > 0) {
44
+ state.lines[state.line] =
45
+ state.lines[state.line].slice(0, state.col - 1) +
46
+ state.lines[state.line].slice(state.col);
47
+ state.col--;
48
+ } else if (state.line > 0) {
49
+ state.col = state.lines[state.line - 1].length;
50
+ state.lines[state.line - 1] += state.lines[state.line];
51
+ state.lines.splice(state.line, 1);
52
+ state.line--;
53
+ }
54
+ setLines([...state.lines]);
55
+ setCursorLine(state.line);
56
+ setCursorCol(state.col);
57
+ return;
58
+ }
59
+
60
+ if (key.upArrow) {
61
+ if (state.line > 0) {
62
+ state.line--;
63
+ state.col = Math.min(state.col, state.lines[state.line].length);
64
+ setCursorLine(state.line);
65
+ setCursorCol(state.col);
66
+ }
67
+ return;
68
+ }
69
+
70
+ if (key.downArrow) {
71
+ if (state.line < state.lines.length - 1) {
72
+ state.line++;
73
+ state.col = Math.min(state.col, state.lines[state.line].length);
74
+ setCursorLine(state.line);
75
+ setCursorCol(state.col);
76
+ }
77
+ return;
78
+ }
79
+
80
+ if (key.leftArrow) {
81
+ if (state.col > 0) {
82
+ state.col--;
83
+ } else if (state.line > 0) {
84
+ state.line--;
85
+ state.col = state.lines[state.line].length;
86
+ }
87
+ setCursorLine(state.line);
88
+ setCursorCol(state.col);
89
+ return;
90
+ }
91
+
92
+ if (key.rightArrow) {
93
+ if (state.col < state.lines[state.line].length) {
94
+ state.col++;
95
+ } else if (state.line < state.lines.length - 1) {
96
+ state.line++;
97
+ state.col = 0;
98
+ }
99
+ setCursorLine(state.line);
100
+ setCursorCol(state.col);
101
+ return;
102
+ }
103
+
104
+ if (input && input.length === 1 && !key.ctrl && !key.meta) {
105
+ if (input === '/' && state.lines.length === 1 && state.lines[0] === '') {
106
+ onSlash?.();
107
+ return;
108
+ }
109
+ state.lines[state.line] =
110
+ state.lines[state.line].slice(0, state.col) +
111
+ input +
112
+ state.lines[state.line].slice(state.col);
113
+ state.col++;
114
+ setLines([...state.lines]);
115
+ setCursorLine(state.line);
116
+ setCursorCol(state.col);
117
+ }
118
+ });
119
+
120
+ const currentText = lines.join('\n');
121
+ const dispLines = currentText ? currentText.split('\n') : [''];
122
+ const height = Math.min(dispLines.length, 5);
123
+ const blankLines = height - dispLines.length;
124
+
125
+ return h(Box, { flexDirection: 'column', flexShrink: 0 },
126
+ h(Box, { flexDirection: 'column', backgroundColor: '#0A0A0A', borderStyle: 'round', borderColor: '#2D2D2D' },
127
+ dispLines.slice(0, height).map((line, i) =>
128
+ h(Box, { key: i, flexDirection: 'row' },
129
+ h(Text, { color: '#00D4FF' }, i === cursorLine ? '\u276F' : ' '),
130
+ h(Text, { color: '#F0F0F0' }, ' ' + (line || ' ')),
131
+ i === cursorLine
132
+ ? h(Text, { color: '#00D4FF' }, '\u258C')
133
+ : null
134
+ )
135
+ ),
136
+ Array.from({ length: blankLines }).map((_, i) =>
137
+ h(Box, { key: 'b' + i, flexDirection: 'row' },
138
+ h(Text, { color: '#2D2D2D' }, ' '),
139
+ h(Text, { color: '#555' }, ' ')
140
+ )
141
+ ),
142
+ h(Box, { flexDirection: 'row', gap: 1, marginTop: 0 },
143
+ h(Text, { color: '#555' }, '\u2502'),
144
+ h(Text, { color: '#888' }, displayName),
145
+ h(Text, { color: '#333' }, '\u00B7'),
146
+ h(Text, { color: agentMode ? '#00FF88' : '#555' }, agentMode ? 'agent:ON' : 'agent:OFF'),
147
+ h(Text, { color: '#333' }, '\u00B7 Ctrl+P commands'),
148
+ h(Text, { color: '#888' }, thinking ? '\u25CF thinking...' : ''),
149
+ )
150
+ )
151
+ );
152
+ }
@@ -1,66 +1,128 @@
1
- import React from 'react';
1
+ import React, { useMemo } from 'react';
2
2
  import { Box, Text } from 'ink';
3
+ import { CodeBlock } from './CodeBlock.js';
4
+ import { ThinkingBlock } from './ThinkingBlock.js';
5
+ import { ToolCard } from './ToolCard.js';
3
6
  const { createElement: h } = React;
4
7
  const termWidth = () => process.stdout.columns || 80;
5
8
 
6
- export function MessageBubble({ msg }) {
9
+ function parseContent(text) {
10
+ if (!text) return [{ type: 'text', content: '' }];
11
+ const parts = [];
12
+ const codeBlockRegex = /```(\w*)\n?([\s\S]*?)```/g;
13
+ let lastIdx = 0;
14
+ let match;
15
+
16
+ while ((match = codeBlockRegex.exec(text)) !== null) {
17
+ if (match.index > lastIdx) {
18
+ parts.push({ type: 'text', content: text.slice(lastIdx, match.index) });
19
+ }
20
+ parts.push({ type: 'code', lang: match[1] || 'text', code: match[2] });
21
+ lastIdx = match.index + match[0].length;
22
+ }
23
+
24
+ if (lastIdx < text.length) {
25
+ parts.push({ type: 'text', content: text.slice(lastIdx) });
26
+ }
27
+
28
+ return parts.length > 0 ? parts : [{ type: 'text', content: text }];
29
+ }
30
+
31
+ function TextContent({ text }) {
32
+ const lines = text.split('\n');
33
+ const w = termWidth();
34
+ return h(Box, { flexDirection: 'column' },
35
+ lines.map((line, i) =>
36
+ h(Box, { key: i, flexDirection: 'row' },
37
+ h(Text, { color: '#7B2FFF', wrap: 'wrap' }, '\u2502'),
38
+ h(Text, { color: '#E0E0E0', wrap: 'wrap' }, ' ' + (line || ' '))
39
+ )
40
+ )
41
+ );
42
+ }
43
+
44
+ function UserContent({ text }) {
45
+ const lines = text.split('\n');
46
+ const w = termWidth();
47
+ return h(Box, { flexDirection: 'column', backgroundColor: '#1A0A2E' },
48
+ lines.map((line, i) =>
49
+ h(Box, { key: i, flexDirection: 'row', backgroundColor: '#1A0A2E' },
50
+ h(Text, { color: '#9B59FF', backgroundColor: '#1A0A2E' }, '\u2502'),
51
+ h(Text, { color: '#C39BD3', backgroundColor: '#1A0A2E', wrap: 'wrap' }, ' ' + (line || ' '))
52
+ )
53
+ )
54
+ );
55
+ }
56
+
57
+ export function MessageBubble({ msg, isStreaming }) {
58
+ const w = termWidth();
59
+
7
60
  if (msg.role === 'user') {
8
- const lines = String(msg.content).split('\n');
9
- return h(Box, { flexDirection: 'column', marginBottom: 1 },
10
- h(Box, null,
11
- h(Text, { color: '#9B59FF', bold: true }, '\u276f YOU '),
12
- h(Text, { color: '#9B59FF' }, '\u2500'.repeat(Math.max(0, termWidth() - 9)))
61
+ return h(Box, { flexDirection: 'column', marginBottom: 1, marginTop: 1 },
62
+ h(Box, { flexDirection: 'row' },
63
+ h(Text, { color: '#FF6B6B', bold: true }, '\u276F'),
64
+ h(Text, { color: '#FF6B6B', bold: true }, ' YOU '),
65
+ h(Text, { color: '#9B59FF' }, '\u2500'.repeat(Math.max(0, w - 14))),
13
66
  ),
14
- lines.map((line, i) =>
15
- h(Text, { key: i, color: '#C39BD3', backgroundColor: '#2D1B4E' },
16
- ' ' + line
17
- )
18
- )
67
+ h(UserContent, { text: String(msg.content) })
19
68
  );
20
69
  }
21
70
 
22
71
  if (msg.role === 'assistant') {
23
- const lines = String(msg.content).split('\n');
72
+ const parts = useMemo(() => parseContent(msg.content), [msg.content]);
24
73
  return h(Box, { flexDirection: 'column', marginBottom: 1 },
25
- h(Box, null,
26
- h(Text, { color: '#7B2FFF', bold: true }, '\u25c6 CLARITY '),
27
- h(Text, { color: '#555555' }, '\u2500'.repeat(Math.max(0, termWidth() - 13)))
74
+ h(Box, { flexDirection: 'row' },
75
+ msg.streaming
76
+ ? h(Text, { color: '#7B2FFF' }, '\u25CF')
77
+ : h(Text, { color: '#7B2FFF', bold: true }, '\u25C6'),
78
+ h(Text, { color: '#7B2FFF', bold: true }, ' CLARITY '),
79
+ h(Text, { color: '#555' }, '\u2500'.repeat(Math.max(0, w - 17))),
80
+ isStreaming
81
+ ? h(Text, { color: '#00D4FF' }, ' \u25CF streaming')
82
+ : null
28
83
  ),
29
- lines.map((line, i) =>
30
- h(Box, { key: i },
31
- h(Text, { color: '#7B2FFF' }, '\u2502'),
32
- h(Text, { color: '#F0F0F0' }, ' ' + line)
33
- )
34
- )
84
+ parts.map((part, i) => {
85
+ if (part.type === 'code') {
86
+ return h(CodeBlock, { key: i, code: part.code, language: part.lang, termWidth: w });
87
+ }
88
+ return h(TextContent, { key: i, text: part.content });
89
+ })
35
90
  );
36
91
  }
37
92
 
38
93
  if (msg.role === 'tool') {
39
- return h(Box, { flexDirection: 'column', marginBottom: 1 },
40
- h(Box, null,
41
- h(Text, { color: '#FFD700', bold: true }, '\u2699 TOOL '),
42
- h(Text, { color: '#555555' }, '\u2500'.repeat(Math.max(0, termWidth() - 11)))
43
- ),
44
- h(Box, { paddingLeft: 2 },
45
- h(Text, { color: '#AAAAAA' }, String(msg.content))
46
- )
47
- );
94
+ return h(ToolCard, {
95
+ name: msg.toolName || 'tool',
96
+ args: msg.args,
97
+ status: msg.status || (msg.error ? 'failed' : 'completed'),
98
+ duration: msg.duration,
99
+ result: msg.content,
100
+ error: msg.error,
101
+ });
48
102
  }
49
103
 
50
104
  if (msg.role === 'error') {
51
- let display = String(msg.content).slice(0, 120);
52
- return h(Box, { paddingLeft: 2, marginBottom: 1 },
53
- h(Text, { color: '#FF4455' }, '\u2716 '),
54
- h(Text, { color: '#FF4455' }, display)
105
+ const display = String(msg.content).slice(0, w - 6);
106
+ return h(Box, { flexDirection: 'row', gap: 1, marginY: 1, paddingLeft: 2 },
107
+ h(Text, { color: '#FF4455' }, '\u2716'),
108
+ h(Text, { color: '#FF4455', wrap: 'wrap' }, display)
55
109
  );
56
110
  }
57
111
 
58
112
  if (msg.role === 'system') {
59
- return h(Box, { paddingLeft: 2, marginBottom: 1 },
60
- h(Text, { color: '#00FF88' }, '\u2714 '),
61
- h(Text, { color: '#00FF88' }, String(msg.content))
113
+ return h(Box, { flexDirection: 'row', gap: 1, marginY: 1, paddingLeft: 2 },
114
+ h(Text, { color: '#00FF88' }, '\u2714'),
115
+ h(Text, { color: '#00FF88', wrap: 'wrap' }, String(msg.content))
62
116
  );
63
117
  }
64
118
 
119
+ if (msg.role === 'thinking') {
120
+ return h(ThinkingBlock, {
121
+ content: msg.content,
122
+ duration: msg.duration,
123
+ depth: msg.depth || 0,
124
+ });
125
+ }
126
+
65
127
  return null;
66
128
  }
@@ -1,16 +1,110 @@
1
- import React from 'react';
2
- import { Box } from 'ink';
1
+ import React, { useState, useRef, useCallback, useMemo } from 'react';
2
+ import { Box, Text, useInput } from 'ink';
3
3
  import { MessageBubble } from './MessageBubble.js';
4
4
  import { LoadingIndicator } from './LoadingIndicator.js';
5
5
  const { createElement: h } = React;
6
6
 
7
- export function MessageList({ messages, thinking, scrollOffset, termHeight }) {
8
- const visible = messages.slice(scrollOffset, scrollOffset + termHeight);
7
+ export function MessageList({ messages, thinking, streamContent, onJumpToLatest }) {
8
+ const [scrollOffset, setScrollOffset] = useState(0);
9
+ const [userScrolled, setUserScrolled] = useState(false);
10
+ const termHeight = process.stdout.rows ? process.stdout.rows - 6 : 20;
11
+ const maxVisible = termHeight - 2;
9
12
 
10
- return h(Box, { flexDirection: 'column' },
11
- visible.map((msg, i) =>
12
- h(MessageBubble, { key: msg.id || i, msg })
13
+ const totalItems = messages.length + (thinking || streamContent ? 1 : 0);
14
+ const atBottom = scrollOffset >= Math.max(0, totalItems - maxVisible);
15
+
16
+ const visibleEnd = Math.min(totalItems, scrollOffset + maxVisible);
17
+ const visibleStart = Math.max(0, visibleEnd - maxVisible);
18
+
19
+ function scrollTo(offset) {
20
+ const max = Math.max(0, totalItems - maxVisible);
21
+ const clamped = Math.max(0, Math.min(max, offset));
22
+ setScrollOffset(clamped);
23
+ if (clamped < max) setUserScrolled(true);
24
+ else setUserScrolled(false);
25
+ }
26
+
27
+ function scrollDown() {
28
+ const max = Math.max(0, totalItems - maxVisible);
29
+ if (scrollOffset < max) {
30
+ setScrollOffset(s => Math.min(max, s + 1));
31
+ setUserScrolled(true);
32
+ }
33
+ }
34
+
35
+ function scrollUp() {
36
+ if (scrollOffset > 0) {
37
+ setScrollOffset(s => Math.max(0, s - 1));
38
+ setUserScrolled(true);
39
+ }
40
+ }
41
+
42
+ function pageDown() {
43
+ const max = Math.max(0, totalItems - maxVisible);
44
+ setScrollOffset(s => {
45
+ const next = Math.min(max, s + maxVisible);
46
+ if (next >= max) setUserScrolled(false);
47
+ else setUserScrolled(true);
48
+ return next;
49
+ });
50
+ }
51
+
52
+ function pageUp() {
53
+ setScrollOffset(s => {
54
+ const next = Math.max(0, s - maxVisible);
55
+ setUserScrolled(true);
56
+ return next;
57
+ });
58
+ }
59
+
60
+ function goHome() {
61
+ setScrollOffset(0);
62
+ setUserScrolled(true);
63
+ }
64
+
65
+ function goEnd() {
66
+ const max = Math.max(0, totalItems - maxVisible);
67
+ setScrollOffset(max);
68
+ setUserScrolled(false);
69
+ }
70
+
71
+ useInput((input, key) => {
72
+ if (key.upArrow && !key.ctrl) scrollUp();
73
+ if (key.downArrow && !key.ctrl) scrollDown();
74
+ if (key.pageUp || (key.downArrow && key.ctrl && !key.shift)) pageUp();
75
+ if (key.pageDown || (key.upArrow && key.ctrl && !key.shift)) pageDown();
76
+ if (key.home) goHome();
77
+ if (key.end) goEnd();
78
+ });
79
+
80
+ if (atBottom && !userScrolled) {
81
+ const max = Math.max(0, totalItems - maxVisible);
82
+ if (scrollOffset !== max) setScrollOffset(max);
83
+ }
84
+
85
+ const visibleMessages = messages.slice(visibleStart, visibleEnd);
86
+
87
+ return h(Box, { flexDirection: 'column', flexGrow: 1 },
88
+ h(Box, { flexDirection: 'column', flexGrow: 1, paddingX: 0 },
89
+ visibleMessages.map((msg) =>
90
+ h(MessageBubble, {
91
+ key: msg.id,
92
+ msg,
93
+ isStreaming: false,
94
+ })
95
+ ),
96
+ (thinking || streamContent) && visibleEnd >= totalItems - 1
97
+ ? streamContent
98
+ ? h(MessageBubble, { key: 'streaming', msg: { id: 'stream', role: 'assistant', content: streamContent, streaming: true }, isStreaming: true })
99
+ : h(LoadingIndicator, { key: 'thinking', label: 'Thinking' })
100
+ : null
13
101
  ),
14
- thinking ? h(LoadingIndicator, { label: 'Thinking' }) : null
102
+ userScrolled
103
+ ? h(Box, { flexDirection: 'row', justifyContent: 'center' },
104
+ h(Text, { color: '#00D4FF', bold: true, backgroundColor: '#1A1A2E' },
105
+ ' \u25B2 Jump to latest (End) '
106
+ )
107
+ )
108
+ : null
15
109
  );
16
110
  }
@@ -7,61 +7,52 @@ export function ModelPicker({ onSelect, onClose }) {
7
7
  const [search, setSearch] = useState('');
8
8
  const [idx, setIdx] = useState(0);
9
9
 
10
- const grouped = useMemo(() => {
10
+ const flat = useMemo(() => {
11
+ const q = search.toLowerCase();
11
12
  const filtered = ALL_MODELS.filter(m =>
12
- m.id.toLowerCase().includes(search.toLowerCase()) ||
13
- m.label.toLowerCase().includes(search.toLowerCase())
13
+ m.id.toLowerCase().includes(q) || m.label.toLowerCase().includes(q)
14
14
  );
15
15
  const groups = {};
16
16
  for (const m of filtered) {
17
17
  if (!groups[m.provider]) groups[m.provider] = [];
18
18
  groups[m.provider].push(m);
19
19
  }
20
- return groups;
21
- }, [search]);
22
-
23
- const flatList = useMemo(() => {
24
20
  const list = [];
25
- for (const [provider, models] of Object.entries(grouped)) {
26
- for (const m of models) {
27
- list.push({ ...m, providerHeader: list.length === 0 || list[list.length - 1]?.provider !== provider });
28
- }
21
+ for (const [provider, models] of Object.entries(groups)) {
22
+ models.forEach((m, i) => {
23
+ if (i === 0) list.push({ ...m, _header: provider });
24
+ else list.push(m);
25
+ });
29
26
  }
30
27
  return list;
31
- }, [grouped]);
28
+ }, [search]);
32
29
 
33
30
  useInput((input, key) => {
34
31
  if (key.upArrow) setIdx(i => Math.max(0, i - 1));
35
- if (key.downArrow) setIdx(i => Math.min(flatList.length - 1, i + 1));
36
- if (key.return) { onSelect(flatList[idx]?.id); return; }
32
+ if (key.downArrow) setIdx(i => Math.min(flat.length - 1, i + 1));
33
+ if (key.return) { if (flat[idx] && !flat[idx]._header) onSelect(flat[idx].id); return; }
37
34
  if (key.escape) onClose();
38
35
  if (key.backspace) setSearch(s => s.slice(0, -1));
39
36
  else if (input && !key.ctrl && !key.meta) setSearch(s => s + input);
40
37
  });
41
38
 
42
- return h(Box, { flexDirection: 'column', paddingX: 1 },
43
- h(Text, { color: '#00D4FF', bold: true }, 'Select model'),
44
- h(Text, { color: '#333333' }, '\u2500'.repeat(30)),
45
- h(Box, { flexDirection: 'row', gap: 1 },
46
- h(Text, { color: '#555555' }, 'Search:'),
47
- h(Text, { color: search ? '#F0F0F0' : '#333333' }, search || '_')
39
+ return h(Box, { flexDirection: 'column' },
40
+ h(Box, { flexDirection: 'row', gap: 1, marginBottom: 1 },
41
+ h(Text, { color: '#555' }, 'Search:'),
42
+ h(Text, { color: search ? '#F0F0F0' : '#333' }, search || 'type to filter...'),
48
43
  ),
49
- h(Text, { color: '#333333' }, ''),
50
- flatList.map((m, i) => {
51
- const items = [];
52
- if (m.providerHeader) {
53
- items.push(h(Text, { key: 'h-' + m.provider, color: '#00D4FF', bold: true }, '\u25BC ' + m.provider.toUpperCase()));
44
+ flat.map((m, i) => {
45
+ if (m._header) {
46
+ return h(Text, { key: 'h-' + m._header, color: '#00D4FF', bold: true }, '\u25BC ' + m._header.toUpperCase());
54
47
  }
55
- items.push(
56
- h(Box, { key: m.id, flexDirection: 'row', gap: 2 },
57
- h(Text, {
58
- color: i === idx ? '#FF6B6B' : '#F0F0F0',
59
- bold: i === idx,
60
- }, i === idx ? '\u25B6 ' : ' ' + m.label),
61
- m.badge ? h(Text, { color: '#555555' }, '[' + m.badge + ']') : null
62
- )
48
+ return h(Box, { key: m.id, flexDirection: 'row', gap: 2 },
49
+ h(Text, {
50
+ color: i === idx ? '#FF6B6B' : '#F0F0F0',
51
+ bold: i === idx,
52
+ backgroundColor: i === idx ? '#1A1A2E' : undefined,
53
+ }, ' ' + m.label),
54
+ m.badge ? h(Text, { color: '#555' }, '[' + m.badge + ']') : null
63
55
  );
64
- return items;
65
56
  })
66
57
  );
67
58
  }
@@ -0,0 +1,30 @@
1
+ import React, { useState } from 'react';
2
+ import { Box, Text } from 'ink';
3
+ const { createElement: h } = React;
4
+
5
+ export function ThinkingBlock({ content, duration, depth = 0 }) {
6
+ const [expanded, setExpanded] = useState(false);
7
+ const lines = String(content || '').split('\n');
8
+ const indent = depth * 2;
9
+
10
+ function toggle() {
11
+ setExpanded(e => !e);
12
+ }
13
+
14
+ const label = expanded ? '\u25BC' : '\u25B6';
15
+ const timeStr = duration ? '(' + duration + 'ms)' : '';
16
+
17
+ return h(Box, { flexDirection: 'column', marginLeft: indent, marginY: 1 },
18
+ h(Box, { flexDirection: 'row', gap: 1 },
19
+ h(Text, { color: '#8B5CF6', bold: true }, label + ' Thinking ' + timeStr),
20
+ ),
21
+ expanded
22
+ ? h(Box, { flexDirection: 'column', paddingLeft: 2,
23
+ borderStyle: 'single', borderColor: '#2D2D2D' },
24
+ lines.map((line, i) =>
25
+ h(Text, { key: i, color: '#A78BFA' }, ' ' + line)
26
+ )
27
+ )
28
+ : null
29
+ );
30
+ }
@@ -0,0 +1,42 @@
1
+ import React from 'react';
2
+ import { Box, Text } from 'ink';
3
+ const { createElement: h } = React;
4
+
5
+ const STATUS_COLORS = {
6
+ running: '#00D4FF',
7
+ completed: '#00FF88',
8
+ failed: '#FF4455',
9
+ cancelled: '#555555',
10
+ };
11
+
12
+ const STATUS_ICONS = {
13
+ running: '\u25CF',
14
+ completed: '\u2714',
15
+ failed: '\u2716',
16
+ cancelled: '\u2716',
17
+ };
18
+
19
+ export function ToolCard({ name, args, status, duration, result, error }) {
20
+ const color = STATUS_COLORS[status] || '#AAAAAA';
21
+ const icon = STATUS_ICONS[status] || '?';
22
+ const argsStr = args ? JSON.stringify(args).slice(0, 80) : '';
23
+ const timeStr = duration ? ' ' + duration + 'ms' : '';
24
+
25
+ return h(Box, { flexDirection: 'column', marginY: 1, marginLeft: 2 },
26
+ h(Box, { flexDirection: 'row', gap: 1 },
27
+ h(Text, { color }, icon + ' ' + name),
28
+ argsStr ? h(Text, { color: '#555' }, argsStr) : null,
29
+ timeStr ? h(Text, { color: '#555' }, timeStr) : null,
30
+ ),
31
+ status === 'completed' && result
32
+ ? h(Box, { paddingLeft: 2 },
33
+ h(Text, { color: '#AAA' }, String(result).slice(0, 200))
34
+ )
35
+ : null,
36
+ status === 'failed' && error
37
+ ? h(Box, { paddingLeft: 2 },
38
+ h(Text, { color: '#FF4455' }, String(error).slice(0, 200))
39
+ )
40
+ : null,
41
+ );
42
+ }
@@ -1,33 +0,0 @@
1
- import React, { useState, useEffect } from 'react';
2
- import { Box, Text } from 'ink';
3
- import TextInput from 'ink-text-input';
4
- const { createElement: h } = React;
5
-
6
- export function InputArea({ onSubmit, onSlash, thinking, inputHistory, historyGoBack, historyGoForward }) {
7
- const [value, setValue] = useState('');
8
-
9
- function handleChange(val) {
10
- setValue(val);
11
- if (val === '/') {
12
- setValue('');
13
- onSlash?.();
14
- }
15
- }
16
-
17
- function handleSubmit(val) {
18
- setValue('');
19
- onSubmit(val);
20
- }
21
-
22
- return h(Box, { flexDirection: 'column', paddingX: 1 },
23
- h(Box, null,
24
- h(Text, { color: '#00FFD1' }, '> '),
25
- h(TextInput, {
26
- value,
27
- onChange: handleChange,
28
- onSubmit: handleSubmit,
29
- placeholder: thinking ? 'Thinking...' : 'Ask anything or type / for commands',
30
- })
31
- )
32
- );
33
- }
@@ -1,43 +0,0 @@
1
- import React, { useState } from 'react';
2
- import { Box, Text } from 'ink';
3
- import TextInput from 'ink-text-input';
4
- const { createElement: h } = React;
5
-
6
- export function PromptBox({ provider, model, agentMode, thinking, onSlash, onSubmit }) {
7
- const [value, setValue] = useState('');
8
- const displayName = provider + '/' + model;
9
-
10
- function handleChange(val) {
11
- setValue(val);
12
- if (val === '/') { setValue(''); onSlash?.(); }
13
- }
14
-
15
- function handleSubmit(val) {
16
- setValue('');
17
- onSubmit(val);
18
- }
19
-
20
- return h(Box, { flexDirection: 'column', flexShrink: 0 },
21
- h(Box, { flexDirection: 'row', gap: 1 },
22
- h(Text, { color: '#00D4FF' }, '\u2502'),
23
- h(Box, { flexDirection: 'column', flexGrow: 1 },
24
- h(Box, { flexDirection: 'row', gap: 1 },
25
- h(Text, { color: '#555555' }, displayName),
26
- h(Text, { color: '#333333' }, ' \u00B7 '),
27
- h(Text, { color: agentMode ? '#00FF88' : '#555555' }, agentMode ? 'agent:ON' : 'agent:OFF'),
28
- h(Text, { color: '#333333' }, ' \u00B7 '),
29
- h(Text, { color: '#555555' }, 'ctrl+p commands')
30
- ),
31
- h(Box, { flexDirection: 'row', gap: 1 },
32
- h(Text, { color: '#00D4FF' }, thinking ? ' \u25CF ' : ' \u276F '),
33
- h(TextInput, {
34
- value,
35
- onChange: handleChange,
36
- onSubmit: handleSubmit,
37
- placeholder: thinking ? 'Thinking...' : 'Ask anything or type / for commands',
38
- })
39
- )
40
- )
41
- )
42
- );
43
- }
@@ -1,22 +0,0 @@
1
- import React from 'react';
2
- import { Box, Text } from 'ink';
3
- const { createElement: h } = React;
4
- const SEP = ' \u00b7 ';
5
-
6
- export function StatusBar({ provider, model, agentMode, tokenCount }) {
7
- return h(Box, { paddingX: 1, paddingTop: 1 },
8
- h(Text, { color: '#00FFD1' }, provider),
9
- h(Text, { color: '#555555' }, SEP),
10
- h(Text, { color: '#F0F0F0' }, model),
11
- h(Text, { color: '#555555' }, SEP),
12
- h(Text, { color: agentMode ? '#00FF88' : '#555555' }, 'agent:' + (agentMode ? 'ON' : 'OFF')),
13
- tokenCount ? h(Text, { color: '#555555' }, SEP + 'ctx:' + formatToken(tokenCount)) : null,
14
- h(Text, { color: '#555555' }, ' ctrl+p commands'),
15
- );
16
- }
17
-
18
- function formatToken(n) {
19
- if (!n) return '0';
20
- if (n < 1000) return String(n);
21
- return (n / 1000).toFixed(1) + 'k';
22
- }