clarity-ai 4.3.1 → 5.1.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.1.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
@@ -2,22 +2,21 @@ import React, { useState, useCallback } 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);
21
20
 
22
21
  const onSubmit = useCallback(async (input) => {
23
22
  if (input.startsWith('/')) {
@@ -27,12 +26,12 @@ export function App({ config }) {
27
26
  return;
28
27
  }
29
28
  if (showBanner) setShowBanner(false);
30
- await handleSend(state, setState, input, model, provider);
31
- }, [state, state.messages, model, provider, showBanner]);
29
+ await handleSend(state, setState, input, model, provider, setStreamContent);
30
+ }, [state, model, provider, showBanner]);
32
31
 
33
- function handleCommandSelect(cmd) {
32
+ function handleCommandSelect(cmdName) {
34
33
  setShowCommands(false);
35
- onSubmit(cmd);
34
+ onSubmit(cmdName);
36
35
  }
37
36
 
38
37
  function handleModelSelect(modelId) {
@@ -52,20 +51,33 @@ export function App({ config }) {
52
51
  h(MessageList, {
53
52
  messages: state.messages,
54
53
  thinking: state.thinking,
55
- scrollOffset,
56
- termHeight,
54
+ streamContent,
55
+ agentStatus: state.agentStatus,
56
+ toolExecutions: state.toolExecutions,
57
57
  })
58
58
  ),
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, {
59
+ showCommands
60
+ ? h(Box, { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, justifyContent: 'center' },
61
+ h(Box, { backgroundColor: '#0A0A0A', borderStyle: 'round', borderColor: '#333', paddingX: 2, paddingY: 1 },
62
+ h(CommandPicker, {
63
+ query: '',
64
+ onSelect: handleCommandSelect,
65
+ onClose: () => setShowCommands(false),
66
+ })
67
+ )
68
+ )
69
+ : null,
70
+ showModels
71
+ ? h(Box, { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, justifyContent: 'center' },
72
+ h(Box, { backgroundColor: '#0A0A0A', borderStyle: 'round', borderColor: '#333', paddingX: 2, paddingY: 1 },
73
+ h(ModelPicker, {
74
+ onSelect: handleModelSelect,
75
+ onClose: () => setShowModels(false),
76
+ })
77
+ )
78
+ )
79
+ : null,
80
+ h(Composer, {
69
81
  provider,
70
82
  model,
71
83
  agentMode: state.agentMode,
package/src/chat.js CHANGED
@@ -1,31 +1,41 @@
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,
16
- tokenCount: 0,
17
- idCounter: 0,
14
+ agentStatus: '',
15
+ toolExecutions: [],
16
+ thoughtTimer: null,
18
17
  };
19
18
  }
20
19
 
21
20
  let msgId = 0;
21
+ let execId = 0;
22
22
  function nextId() { return 'm' + (++msgId); }
23
+ function nextExecId() { return 'x' + (++execId); }
23
24
 
24
- export async function handleSend(state, setState, input, model, provider) {
25
+ export async function handleSend(state, setState, input, model, provider, onStreamContent) {
25
26
  if (!input.trim() || state.awaitingKey) return;
26
27
 
27
28
  const userMsg = { id: nextId(), role: 'user', content: input };
28
- setState(s => ({ ...s, messages: [...s.messages, userMsg], thinking: true, streamBuffer: '' }));
29
+ setState(s => ({
30
+ ...s,
31
+ messages: [...s.messages, userMsg],
32
+ thinking: true,
33
+ streamContent: '',
34
+ agentStatus: 'Processing...',
35
+ toolExecutions: [],
36
+ thoughtTimer: Date.now(),
37
+ }));
38
+ onStreamContent('');
29
39
 
30
40
  try {
31
41
  const history = [...state.messages, userMsg].map(m => {
@@ -33,35 +43,36 @@ export async function handleSend(state, setState, input, model, provider) {
33
43
  role: m.role === 'error' ? 'assistant' : m.role,
34
44
  content: m.content,
35
45
  };
36
- if (m.role === 'tool' && m.tool_call_id) {
37
- base.tool_call_id = m.tool_call_id;
38
- }
46
+ if (m.role === 'tool' && m.tool_call_id) base.tool_call_id = m.tool_call_id;
39
47
  if (m.role === 'assistant' && m.toolCalls) {
40
48
  base.tool_calls = m.toolCalls.map(tc => ({
41
- id: tc.id,
42
- type: 'function',
49
+ id: tc.id, type: 'function',
43
50
  function: { name: tc.function.name, arguments: tc.function.arguments },
44
51
  }));
45
52
  }
46
53
  return base;
47
54
  });
48
55
 
49
- await processStream(provider, model, history, state.agentMode, setState);
56
+ await processStream(provider, model, history, state.agentMode, setState, onStreamContent);
50
57
  } catch (err) {
51
- if (err.type) {
52
- handleError(setState, err);
53
- } else {
54
- setState(s => ({
55
- ...s, thinking: false,
56
- messages: [...s.messages, { id: nextId(), role: 'error', content: err.message || 'Unknown error' }],
57
- }));
58
- }
58
+ const thoughtTime = state.thoughtTimer ? Date.now() - state.thoughtTimer : 0;
59
+ setState(s => ({
60
+ ...s, thinking: false, streamContent: '', agentStatus: '', toolExecutions: [], thoughtTimer: null,
61
+ messages: [...s.messages, {
62
+ id: nextId(), role: 'error',
63
+ content: err.hint || err.message || 'Request failed',
64
+ type: err.type,
65
+ duration: thoughtTime,
66
+ }],
67
+ }));
68
+ onStreamContent('');
59
69
  }
60
70
  }
61
71
 
62
- async function processStream(provider, model, history, agentMode, setState, depth = 0) {
63
- if (depth > 10) {
64
- setState(s => ({ ...s, thinking: false, streamBuffer: '' }));
72
+ async function processStream(provider, model, history, agentMode, setState, onStreamContent, depth = 0) {
73
+ if (depth > 8) {
74
+ setState(s => ({ ...s, thinking: false, streamContent: '', agentStatus: '', toolExecutions: [], thoughtTimer: null }));
75
+ onStreamContent('');
65
76
  return;
66
77
  }
67
78
 
@@ -69,102 +80,169 @@ async function processStream(provider, model, history, agentMode, setState, dept
69
80
  try {
70
81
  stream = callAI(provider, model, history, { tools: agentMode ? TOOLS : undefined });
71
82
  } catch (err) {
72
- if (err.type === 'rate_limit') {
73
- await sleep(2000);
74
- return processStream(provider, model, history, agentMode, setState, depth);
75
- }
83
+ if (err.type === 'rate_limit') { await sleep(2000); return processStream(provider, model, history, agentMode, setState, onStreamContent, depth); }
76
84
  throw err;
77
85
  }
78
86
 
79
87
  let buffer = '';
80
- let toolCalls = null;
88
+ let toolCallsData = null;
89
+ let thoughtTime = Date.now();
81
90
 
82
91
  try {
83
92
  for await (const event of stream) {
84
93
  if (event.type === 'token') {
85
94
  buffer += event.content;
95
+ onStreamContent(buffer);
96
+ setState(s => ({ ...s, agentStatus: 'Writing response...' }));
97
+ } else if (event.type === 'tool_calls') {
98
+ toolCallsData = event.calls;
99
+ } else if (event.type === 'done') {
100
+ } else if (event.type === 'timeout') {
86
101
  setState(s => ({
87
- ...s,
88
- streamBuffer: buffer,
89
- messages: updateLastAssistant(s.messages, buffer),
102
+ ...s, agentStatus: 'Stream stalled, completing...',
103
+ messages: [...s.messages, { id: nextId(), role: 'system', content: 'Stream timeout — response may be incomplete' }],
90
104
  }));
91
- } else if (event.type === 'tool_calls') {
92
- toolCalls = event.calls;
93
105
  } else if (event.type === 'error') {
94
- handleError(setState, event);
106
+ setState(s => ({ ...s, thinking: false, streamContent: '', agentStatus: '', toolExecutions: [], thoughtTimer: null }));
107
+ setState(s => ({
108
+ ...s,
109
+ messages: [...s.messages, { id: nextId(), role: 'error', content: event.hint || event.message }],
110
+ }));
111
+ onStreamContent('');
95
112
  return;
96
113
  }
97
114
  }
98
115
  } catch (err) {
99
116
  if (err.type === 'rate_limit') {
100
117
  await sleep(2000);
101
- return processStream(provider, model, history, agentMode, setState, depth);
118
+ setState(s => ({ ...s, agentStatus: 'Retrying after rate limit...' }));
119
+ return processStream(provider, model, history, agentMode, setState, onStreamContent, depth);
102
120
  }
103
121
  throw err;
104
122
  }
105
123
 
106
- if (toolCalls && toolCalls.length > 0 && agentMode) {
124
+ const elapsed = Date.now() - thoughtTime;
125
+
126
+ if (buffer) {
127
+ setState(s => ({
128
+ ...s,
129
+ messages: [...s.messages, { id: nextId(), role: 'assistant', content: buffer, duration: elapsed }],
130
+ streamContent: '',
131
+ agentStatus: '',
132
+ }));
133
+ onStreamContent('');
134
+ } else {
135
+ setState(s => ({ ...s, streamContent: '', agentStatus: '' }));
136
+ onStreamContent('');
137
+ }
138
+
139
+ if (toolCallsData && toolCallsData.length > 0 && agentMode) {
140
+ const execs = toolCallsData.map(tc => ({
141
+ execId: nextExecId(),
142
+ tcId: tc.id,
143
+ name: tc.function.name,
144
+ args: tc.function.arguments,
145
+ status: 'running',
146
+ startTime: Date.now(),
147
+ duration: 0,
148
+ result: '',
149
+ }));
150
+
151
+ setState(s => ({
152
+ ...s,
153
+ toolExecutions: execs,
154
+ agentStatus: 'Running tools...',
155
+ }));
156
+
107
157
  const toolResults = [];
108
- for (const tc of toolCalls) {
158
+ for (let i = 0; i < toolCallsData.length; i++) {
159
+ const tc = toolCallsData[i];
109
160
  const { name, arguments: argsStr } = tc.function;
110
161
  let args;
111
162
  try { args = JSON.parse(argsStr); } catch { args = {}; }
112
- const result = await executeTool(name, args);
163
+
164
+ setState(s => ({
165
+ ...s,
166
+ agentStatus: '' + name + '(' + JSON.stringify(args).slice(0, 60) + ')',
167
+ toolExecutions: s.toolExecutions.map(x =>
168
+ x.execId === execs[i].execId ? { ...x, status: 'running' } : x
169
+ ),
170
+ }));
171
+
172
+ let result, error;
173
+ const toolStart = Date.now();
174
+ try {
175
+ result = await executeTool(name, args);
176
+ } catch (e) {
177
+ error = e.message;
178
+ result = 'Error: ' + e.message;
179
+ }
180
+ const toolDuration = Date.now() - toolStart;
181
+
113
182
  toolResults.push({ tool_call_id: tc.id, role: 'tool', content: result, name });
183
+
184
+ setState(s => ({
185
+ ...s,
186
+ toolExecutions: s.toolExecutions.map(x =>
187
+ x.execId === execs[i].execId
188
+ ? { ...x, status: error ? 'failed' : 'completed', duration: toolDuration, result, error }
189
+ : x
190
+ ),
191
+ }));
114
192
  }
115
193
 
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,
194
+ setState(s => ({
195
+ ...s,
196
+ agentStatus: 'Processing results...',
120
197
  }));
121
198
 
122
- setState(s => ({ ...s, messages: [...s.messages, newAssistantMsg, ...toolMsgs] }));
199
+ const totalThoughtTime = Date.now() - thoughtTime;
200
+
201
+ setState(s => ({
202
+ ...s,
203
+ messages: [
204
+ ...s.messages,
205
+ {
206
+ id: nextId(),
207
+ role: 'assistant',
208
+ content: buffer || '',
209
+ toolCalls: toolCallsData,
210
+ toolResults: toolResults.map((tr, i) => ({
211
+ ...tr,
212
+ execId: execs[i]?.execId,
213
+ duration: execs[i]?.duration,
214
+ status: execs[i]?.status,
215
+ })),
216
+ duration: totalThoughtTime,
217
+ },
218
+ ...toolResults.map(tr => ({
219
+ id: nextId(), role: 'tool', content: tr.content,
220
+ tool_call_id: tr.tool_call_id, toolName: tr.name,
221
+ })),
222
+ ],
223
+ toolExecutions: [],
224
+ agentStatus: '',
225
+ }));
123
226
 
124
227
  const newHistory = [
125
228
  ...history,
126
- { role: 'assistant', content: buffer || null, tool_calls: toolCalls.map(tc => ({
127
- id: tc.id,
128
- type: 'function',
229
+ { role: 'assistant', content: buffer || null, tool_calls: toolCallsData.map(tc => ({
230
+ id: tc.id, type: 'function',
129
231
  function: { name: tc.function.name, arguments: tc.function.arguments },
130
232
  }))},
131
233
  ...toolResults,
132
234
  ];
133
235
 
134
- await processStream(provider, model, newHistory, agentMode, setState, depth + 1);
236
+ await processStream(provider, model, newHistory, agentMode, setState, onStreamContent, depth + 1);
135
237
  } else {
136
- setState(s => ({ ...s, thinking: false, streamBuffer: '' }));
137
- }
138
- }
139
-
140
- 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
238
  setState(s => ({
148
- ...s, thinking: false,
149
- messages: [...s.messages, { id: nextId(), role: 'error', content: err.message }],
239
+ ...s,
240
+ thinking: false,
241
+ agentStatus: '',
242
+ toolExecutions: [],
243
+ thoughtTimer: null,
150
244
  }));
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
245
  }
167
- return copy;
168
246
  }
169
247
 
170
248
  export async function handleCommand(input, state, setState, modelSetter, providerSetter, model, provider) {
@@ -194,22 +272,16 @@ export async function handleCommand(input, state, setState, modelSetter, provide
194
272
  }));
195
273
  break;
196
274
  case '/clear':
197
- setState(s => ({ ...s, messages: [], streamBuffer: '' }));
198
- break;
199
- case '/theme':
200
- setState(s => ({
201
- ...s,
202
- messages: [...s.messages, { id: nextId(), role: 'system', content: 'Themes: dark (only theme available)' }],
203
- }));
275
+ setState(s => ({ ...s, messages: [], streamContent: '', toolExecutions: [], agentStatus: '' }));
204
276
  break;
205
277
  case '/export': {
206
278
  const text = state.messages.map(m => '[' + m.role + '] ' + m.content).join('\n\n');
207
279
  const { writeFileSync } = await import('fs');
208
- const path = 'clarity-export-' + Date.now() + '.md';
209
- writeFileSync(path, text);
280
+ const fpath = 'clarity-export-' + Date.now() + '.md';
281
+ writeFileSync(fpath, text);
210
282
  setState(s => ({
211
283
  ...s,
212
- messages: [...s.messages, { id: nextId(), role: 'system', content: 'Exported to ' + path }],
284
+ messages: [...s.messages, { id: nextId(), role: 'system', content: 'Exported to ' + fpath }],
213
285
  }));
214
286
  break;
215
287
  }
@@ -220,7 +292,6 @@ export async function handleCommand(input, state, setState, modelSetter, provide
220
292
  '/provider Switch provider',
221
293
  '/agent Toggle agent mode',
222
294
  '/clear Clear conversation',
223
- '/theme Change color theme',
224
295
  '/export Export conversation',
225
296
  '/help Show this help',
226
297
  '/exit Exit CLARITY',
@@ -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
  }