clarity-ai 4.3.1 → 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.1",
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,15 +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';
5
-
6
4
  const sleep = ms => new Promise(r => setTimeout(r, ms));
7
5
 
8
6
  export function createChatState() {
9
7
  return {
10
8
  messages: [],
11
9
  thinking: false,
12
- streamBuffer: '',
10
+ streamContent: '',
13
11
  awaitingKey: false,
14
12
  blockedProvider: null,
15
13
  agentMode: true,
@@ -21,11 +19,17 @@ export function createChatState() {
21
19
  let msgId = 0;
22
20
  function nextId() { return 'm' + (++msgId); }
23
21
 
24
- export async function handleSend(state, setState, input, model, provider) {
22
+ export async function handleSend(state, setState, input, model, provider, onStreamContent) {
25
23
  if (!input.trim() || state.awaitingKey) return;
26
24
 
27
25
  const userMsg = { id: nextId(), role: 'user', content: input };
28
- 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('');
29
33
 
30
34
  try {
31
35
  const history = [...state.messages, userMsg].map(m => {
@@ -38,30 +42,31 @@ export async function handleSend(state, setState, input, model, provider) {
38
42
  }
39
43
  if (m.role === 'assistant' && m.toolCalls) {
40
44
  base.tool_calls = m.toolCalls.map(tc => ({
41
- id: tc.id,
42
- type: 'function',
45
+ id: tc.id, type: 'function',
43
46
  function: { name: tc.function.name, arguments: tc.function.arguments },
44
47
  }));
45
48
  }
46
49
  return base;
47
50
  });
48
51
 
49
- await processStream(provider, model, history, state.agentMode, setState);
52
+ await processStream(provider, model, history, state.agentMode, setState, onStreamContent);
50
53
  } catch (err) {
51
54
  if (err.type) {
52
55
  handleError(setState, err);
53
56
  } else {
54
57
  setState(s => ({
55
- ...s, thinking: false,
58
+ ...s, thinking: false, streamContent: '',
56
59
  messages: [...s.messages, { id: nextId(), role: 'error', content: err.message || 'Unknown error' }],
57
60
  }));
58
61
  }
62
+ onStreamContent('');
59
63
  }
60
64
  }
61
65
 
62
- async function processStream(provider, model, history, agentMode, setState, depth = 0) {
66
+ async function processStream(provider, model, history, agentMode, setState, onStreamContent, depth = 0) {
63
67
  if (depth > 10) {
64
- setState(s => ({ ...s, thinking: false, streamBuffer: '' }));
68
+ setState(s => ({ ...s, thinking: false, streamContent: '' }));
69
+ onStreamContent('');
65
70
  return;
66
71
  }
67
72
 
@@ -69,10 +74,7 @@ async function processStream(provider, model, history, agentMode, setState, dept
69
74
  try {
70
75
  stream = callAI(provider, model, history, { tools: agentMode ? TOOLS : undefined });
71
76
  } catch (err) {
72
- if (err.type === 'rate_limit') {
73
- await sleep(2000);
74
- return processStream(provider, model, history, agentMode, setState, depth);
75
- }
77
+ if (err.type === 'rate_limit') { await sleep(2000); return processStream(provider, model, history, agentMode, setState, onStreamContent, depth); }
76
78
  throw err;
77
79
  }
78
80
 
@@ -83,26 +85,32 @@ async function processStream(provider, model, history, agentMode, setState, dept
83
85
  for await (const event of stream) {
84
86
  if (event.type === 'token') {
85
87
  buffer += event.content;
86
- setState(s => ({
87
- ...s,
88
- streamBuffer: buffer,
89
- messages: updateLastAssistant(s.messages, buffer),
90
- }));
88
+ onStreamContent(buffer);
91
89
  } else if (event.type === 'tool_calls') {
92
90
  toolCalls = event.calls;
93
91
  } else if (event.type === 'error') {
94
92
  handleError(setState, event);
93
+ onStreamContent('');
95
94
  return;
96
95
  }
97
96
  }
98
97
  } catch (err) {
99
- if (err.type === 'rate_limit') {
100
- await sleep(2000);
101
- return processStream(provider, model, history, agentMode, setState, depth);
102
- }
98
+ if (err.type === 'rate_limit') { await sleep(2000); return processStream(provider, model, history, agentMode, setState, onStreamContent, depth); }
103
99
  throw err;
104
100
  }
105
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('');
112
+ }
113
+
106
114
  if (toolCalls && toolCalls.length > 0 && agentMode) {
107
115
  const toolResults = [];
108
116
  for (const tc of toolCalls) {
@@ -113,58 +121,41 @@ async function processStream(provider, model, history, agentMode, setState, dept
113
121
  toolResults.push({ tool_call_id: tc.id, role: 'tool', content: result, name });
114
122
  }
115
123
 
116
- const newAssistantMsg = { id: nextId(), role: 'assistant', content: buffer || '', toolCalls };
117
- const toolMsgs = toolResults.map(tr => ({
118
- id: nextId(), role: 'tool', content: tr.content,
119
- tool_call_id: tr.tool_call_id, toolName: tr.name,
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
+ ],
120
134
  }));
121
135
 
122
- setState(s => ({ ...s, messages: [...s.messages, newAssistantMsg, ...toolMsgs] }));
123
-
124
136
  const newHistory = [
125
137
  ...history,
126
138
  { role: 'assistant', content: buffer || null, tool_calls: toolCalls.map(tc => ({
127
- id: tc.id,
128
- type: 'function',
139
+ id: tc.id, type: 'function',
129
140
  function: { name: tc.function.name, arguments: tc.function.arguments },
130
141
  }))},
131
142
  ...toolResults,
132
143
  ];
133
144
 
134
- await processStream(provider, model, newHistory, agentMode, setState, depth + 1);
135
- } else {
136
- setState(s => ({ ...s, thinking: false, streamBuffer: '' }));
145
+ await processStream(provider, model, newHistory, agentMode, setState, onStreamContent, depth + 1);
137
146
  }
138
147
  }
139
148
 
140
149
  function handleError(setState, err) {
141
- if (err.type === 'auth_error') {
142
- setState(s => ({
143
- ...s, thinking: false, awaitingKey: true, blockedProvider: err.provider,
144
- messages: [...s.messages, { id: nextId(), role: 'error', content: err.hint || err.message }],
145
- }));
146
- } else if (err.type === 'rate_limit') {
147
- setState(s => ({
148
- ...s, thinking: false,
149
- messages: [...s.messages, { id: nextId(), role: 'error', content: err.message }],
150
- }));
151
- } else {
152
- setState(s => ({
153
- ...s, thinking: false,
154
- messages: [...s.messages, { id: nextId(), role: 'error', content: err.message }],
155
- }));
156
- }
157
- }
158
-
159
- function updateLastAssistant(messages, buffer) {
160
- const copy = [...messages];
161
- const last = copy[copy.length - 1];
162
- if (last && last.role === 'assistant') {
163
- copy[copy.length - 1] = { ...last, content: buffer, streaming: true };
164
- } else {
165
- copy.push({ id: nextId(), role: 'assistant', content: buffer, streaming: true });
166
- }
167
- 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
+ }));
168
159
  }
169
160
 
170
161
  export async function handleCommand(input, state, setState, modelSetter, providerSetter, model, provider) {
@@ -194,7 +185,7 @@ export async function handleCommand(input, state, setState, modelSetter, provide
194
185
  }));
195
186
  break;
196
187
  case '/clear':
197
- setState(s => ({ ...s, messages: [], streamBuffer: '' }));
188
+ setState(s => ({ ...s, messages: [], streamContent: '' }));
198
189
  break;
199
190
  case '/theme':
200
191
  setState(s => ({
@@ -205,11 +196,11 @@ export async function handleCommand(input, state, setState, modelSetter, provide
205
196
  case '/export': {
206
197
  const text = state.messages.map(m => '[' + m.role + '] ' + m.content).join('\n\n');
207
198
  const { writeFileSync } = await import('fs');
208
- const path = 'clarity-export-' + Date.now() + '.md';
209
- writeFileSync(path, text);
199
+ const fpath = 'clarity-export-' + Date.now() + '.md';
200
+ writeFileSync(fpath, text);
210
201
  setState(s => ({
211
202
  ...s,
212
- messages: [...s.messages, { id: nextId(), role: 'system', content: 'Exported to ' + path }],
203
+ messages: [...s.messages, { id: nextId(), role: 'system', content: 'Exported to ' + fpath }],
213
204
  }));
214
205
  break;
215
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
- }