clarity-ai 5.0.0 → 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,6 +1,6 @@
1
1
  {
2
2
  "name": "clarity-ai",
3
- "version": "5.0.0",
3
+ "version": "5.1.0",
4
4
  "description": "Premium OpenCode-style terminal AI agent — streaming, tools, multiline composer, virtual scroll, code blocks",
5
5
  "type": "module",
6
6
  "bin": {
package/src/app.js CHANGED
@@ -1,4 +1,4 @@
1
- import React, { useState, useCallback, useRef, useEffect } from 'react';
1
+ 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';
@@ -17,7 +17,6 @@ export function App({ config }) {
17
17
  const [showCommands, setShowCommands] = useState(false);
18
18
  const [showModels, setShowModels] = useState(false);
19
19
  const [showBanner, setShowBanner] = useState(true);
20
- const streamRef = useRef('');
21
20
 
22
21
  const onSubmit = useCallback(async (input) => {
23
22
  if (input.startsWith('/')) {
@@ -53,10 +52,12 @@ export function App({ config }) {
53
52
  messages: state.messages,
54
53
  thinking: state.thinking,
55
54
  streamContent,
55
+ agentStatus: state.agentStatus,
56
+ toolExecutions: state.toolExecutions,
56
57
  })
57
58
  ),
58
59
  showCommands
59
- ? h(Box, { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, justifyContent: 'center', alignItems: 'center' },
60
+ ? h(Box, { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, justifyContent: 'center' },
60
61
  h(Box, { backgroundColor: '#0A0A0A', borderStyle: 'round', borderColor: '#333', paddingX: 2, paddingY: 1 },
61
62
  h(CommandPicker, {
62
63
  query: '',
@@ -67,7 +68,7 @@ export function App({ config }) {
67
68
  )
68
69
  : null,
69
70
  showModels
70
- ? h(Box, { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, justifyContent: 'center', alignItems: 'center' },
71
+ ? h(Box, { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, justifyContent: 'center' },
71
72
  h(Box, { backgroundColor: '#0A0A0A', borderStyle: 'round', borderColor: '#333', paddingX: 2, paddingY: 1 },
72
73
  h(ModelPicker, {
73
74
  onSelect: handleModelSelect,
package/src/chat.js CHANGED
@@ -11,13 +11,16 @@ export function createChatState() {
11
11
  awaitingKey: false,
12
12
  blockedProvider: null,
13
13
  agentMode: true,
14
- tokenCount: 0,
15
- idCounter: 0,
14
+ agentStatus: '',
15
+ toolExecutions: [],
16
+ thoughtTimer: null,
16
17
  };
17
18
  }
18
19
 
19
20
  let msgId = 0;
21
+ let execId = 0;
20
22
  function nextId() { return 'm' + (++msgId); }
23
+ function nextExecId() { return 'x' + (++execId); }
21
24
 
22
25
  export async function handleSend(state, setState, input, model, provider, onStreamContent) {
23
26
  if (!input.trim() || state.awaitingKey) return;
@@ -28,6 +31,9 @@ export async function handleSend(state, setState, input, model, provider, onStre
28
31
  messages: [...s.messages, userMsg],
29
32
  thinking: true,
30
33
  streamContent: '',
34
+ agentStatus: 'Processing...',
35
+ toolExecutions: [],
36
+ thoughtTimer: Date.now(),
31
37
  }));
32
38
  onStreamContent('');
33
39
 
@@ -37,9 +43,7 @@ export async function handleSend(state, setState, input, model, provider, onStre
37
43
  role: m.role === 'error' ? 'assistant' : m.role,
38
44
  content: m.content,
39
45
  };
40
- if (m.role === 'tool' && m.tool_call_id) {
41
- base.tool_call_id = m.tool_call_id;
42
- }
46
+ if (m.role === 'tool' && m.tool_call_id) base.tool_call_id = m.tool_call_id;
43
47
  if (m.role === 'assistant' && m.toolCalls) {
44
48
  base.tool_calls = m.toolCalls.map(tc => ({
45
49
  id: tc.id, type: 'function',
@@ -51,21 +55,23 @@ export async function handleSend(state, setState, input, model, provider, onStre
51
55
 
52
56
  await processStream(provider, model, history, state.agentMode, setState, onStreamContent);
53
57
  } catch (err) {
54
- if (err.type) {
55
- handleError(setState, err);
56
- } else {
57
- setState(s => ({
58
- ...s, thinking: false, streamContent: '',
59
- messages: [...s.messages, { id: nextId(), role: 'error', content: err.message || 'Unknown error' }],
60
- }));
61
- }
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
+ }));
62
68
  onStreamContent('');
63
69
  }
64
70
  }
65
71
 
66
72
  async function processStream(provider, model, history, agentMode, setState, onStreamContent, depth = 0) {
67
- if (depth > 10) {
68
- setState(s => ({ ...s, thinking: false, streamContent: '' }));
73
+ if (depth > 8) {
74
+ setState(s => ({ ...s, thinking: false, streamContent: '', agentStatus: '', toolExecutions: [], thoughtTimer: null }));
69
75
  onStreamContent('');
70
76
  return;
71
77
  }
@@ -79,63 +85,148 @@ async function processStream(provider, model, history, agentMode, setState, onSt
79
85
  }
80
86
 
81
87
  let buffer = '';
82
- let toolCalls = null;
88
+ let toolCallsData = null;
89
+ let thoughtTime = Date.now();
83
90
 
84
91
  try {
85
92
  for await (const event of stream) {
86
93
  if (event.type === 'token') {
87
94
  buffer += event.content;
88
95
  onStreamContent(buffer);
96
+ setState(s => ({ ...s, agentStatus: 'Writing response...' }));
89
97
  } else if (event.type === 'tool_calls') {
90
- toolCalls = event.calls;
98
+ toolCallsData = event.calls;
99
+ } else if (event.type === 'done') {
100
+ } else if (event.type === 'timeout') {
101
+ setState(s => ({
102
+ ...s, agentStatus: 'Stream stalled, completing...',
103
+ messages: [...s.messages, { id: nextId(), role: 'system', content: 'Stream timeout — response may be incomplete' }],
104
+ }));
91
105
  } else if (event.type === 'error') {
92
- 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
+ }));
93
111
  onStreamContent('');
94
112
  return;
95
113
  }
96
114
  }
97
115
  } catch (err) {
98
- if (err.type === 'rate_limit') { await sleep(2000); return processStream(provider, model, history, agentMode, setState, onStreamContent, depth); }
116
+ if (err.type === 'rate_limit') {
117
+ await sleep(2000);
118
+ setState(s => ({ ...s, agentStatus: 'Retrying after rate limit...' }));
119
+ return processStream(provider, model, history, agentMode, setState, onStreamContent, depth);
120
+ }
99
121
  throw err;
100
122
  }
101
123
 
124
+ const elapsed = Date.now() - thoughtTime;
125
+
102
126
  if (buffer) {
103
127
  setState(s => ({
104
128
  ...s,
105
- messages: [...s.messages, { id: nextId(), role: 'assistant', content: buffer, streaming: false }],
129
+ messages: [...s.messages, { id: nextId(), role: 'assistant', content: buffer, duration: elapsed }],
106
130
  streamContent: '',
131
+ agentStatus: '',
107
132
  }));
108
133
  onStreamContent('');
109
134
  } else {
110
- setState(s => ({ ...s, streamContent: '' }));
135
+ setState(s => ({ ...s, streamContent: '', agentStatus: '' }));
111
136
  onStreamContent('');
112
137
  }
113
138
 
114
- if (toolCalls && toolCalls.length > 0 && agentMode) {
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
+
115
157
  const toolResults = [];
116
- for (const tc of toolCalls) {
158
+ for (let i = 0; i < toolCallsData.length; i++) {
159
+ const tc = toolCallsData[i];
117
160
  const { name, arguments: argsStr } = tc.function;
118
161
  let args;
119
162
  try { args = JSON.parse(argsStr); } catch { args = {}; }
120
- 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
+
121
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
+ }));
122
192
  }
123
193
 
194
+ setState(s => ({
195
+ ...s,
196
+ agentStatus: 'Processing results...',
197
+ }));
198
+
199
+ const totalThoughtTime = Date.now() - thoughtTime;
200
+
124
201
  setState(s => ({
125
202
  ...s,
126
203
  messages: [
127
204
  ...s.messages,
128
- { id: nextId(), role: 'assistant', content: buffer || '', toolCalls, streaming: false },
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
+ },
129
218
  ...toolResults.map(tr => ({
130
219
  id: nextId(), role: 'tool', content: tr.content,
131
220
  tool_call_id: tr.tool_call_id, toolName: tr.name,
132
221
  })),
133
222
  ],
223
+ toolExecutions: [],
224
+ agentStatus: '',
134
225
  }));
135
226
 
136
227
  const newHistory = [
137
228
  ...history,
138
- { role: 'assistant', content: buffer || null, tool_calls: toolCalls.map(tc => ({
229
+ { role: 'assistant', content: buffer || null, tool_calls: toolCallsData.map(tc => ({
139
230
  id: tc.id, type: 'function',
140
231
  function: { name: tc.function.name, arguments: tc.function.arguments },
141
232
  }))},
@@ -143,21 +234,17 @@ async function processStream(provider, model, history, agentMode, setState, onSt
143
234
  ];
144
235
 
145
236
  await processStream(provider, model, newHistory, agentMode, setState, onStreamContent, depth + 1);
237
+ } else {
238
+ setState(s => ({
239
+ ...s,
240
+ thinking: false,
241
+ agentStatus: '',
242
+ toolExecutions: [],
243
+ thoughtTimer: null,
244
+ }));
146
245
  }
147
246
  }
148
247
 
149
- function handleError(setState, err) {
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
- }));
159
- }
160
-
161
248
  export async function handleCommand(input, state, setState, modelSetter, providerSetter, model, provider) {
162
249
  const parts = input.trim().split(/\s+/);
163
250
  const cmd = parts[0];
@@ -185,13 +272,7 @@ export async function handleCommand(input, state, setState, modelSetter, provide
185
272
  }));
186
273
  break;
187
274
  case '/clear':
188
- setState(s => ({ ...s, messages: [], streamContent: '' }));
189
- break;
190
- case '/theme':
191
- setState(s => ({
192
- ...s,
193
- messages: [...s.messages, { id: nextId(), role: 'system', content: 'Themes: dark (only theme available)' }],
194
- }));
275
+ setState(s => ({ ...s, messages: [], streamContent: '', toolExecutions: [], agentStatus: '' }));
195
276
  break;
196
277
  case '/export': {
197
278
  const text = state.messages.map(m => '[' + m.role + '] ' + m.content).join('\n\n');
@@ -211,7 +292,6 @@ export async function handleCommand(input, state, setState, modelSetter, provide
211
292
  '/provider Switch provider',
212
293
  '/agent Toggle agent mode',
213
294
  '/clear Clear conversation',
214
- '/theme Change color theme',
215
295
  '/export Export conversation',
216
296
  '/help Show this help',
217
297
  '/exit Exit CLARITY',
@@ -1,75 +1,102 @@
1
- import React, { useMemo } from 'react';
1
+ import React, { useMemo, useState } from 'react';
2
2
  import { Box, Text } from 'ink';
3
3
  import { CodeBlock } from './CodeBlock.js';
4
4
  import { ThinkingBlock } from './ThinkingBlock.js';
5
5
  import { ToolCard } from './ToolCard.js';
6
6
  const { createElement: h } = React;
7
- const termWidth = () => process.stdout.columns || 80;
7
+ const tw = () => process.stdout.columns || 80;
8
8
 
9
9
  function parseContent(text) {
10
10
  if (!text) return [{ type: 'text', content: '' }];
11
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) });
12
+ const cb = /```(\w*)\n?([\s\S]*?)```/g;
13
+ let last = 0, m;
14
+ while ((m = cb.exec(text)) !== null) {
15
+ if (m.index > last) parts.push({ type: 'text', content: text.slice(last, m.index) });
16
+ parts.push({ type: 'code', lang: m[1] || 'text', code: m[2] });
17
+ last = m.index + m[0].length;
26
18
  }
27
-
28
- return parts.length > 0 ? parts : [{ type: 'text', content: text }];
19
+ if (last < text.length) parts.push({ type: 'text', content: text.slice(last) });
20
+ return parts.length ? parts : [{ type: 'text', content: text }];
29
21
  }
30
22
 
31
- function TextContent({ text }) {
23
+ function TextContent({ text, color }) {
32
24
  const lines = text.split('\n');
33
- const w = termWidth();
34
25
  return h(Box, { flexDirection: 'column' },
35
26
  lines.map((line, i) =>
36
27
  h(Box, { key: i, flexDirection: 'row' },
37
- h(Text, { color: '#7B2FFF', wrap: 'wrap' }, '\u2502'),
28
+ h(Text, { color: color || '#7B2FFF' }, '\u2502'),
38
29
  h(Text, { color: '#E0E0E0', wrap: 'wrap' }, ' ' + (line || ' '))
39
30
  )
40
31
  )
41
32
  );
42
33
  }
43
34
 
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
- )
35
+ function ThoughtBlock({ toolResults, duration }) {
36
+ const [collapsed, setCollapsed] = useState(true);
37
+ const items = toolResults || [];
38
+ const timeStr = duration ? (duration < 1000 ? duration + 'ms' : (duration / 1000).toFixed(1) + 's') : '';
39
+
40
+ return h(Box, { flexDirection: 'column', marginY: 0 },
41
+ h(Box, { flexDirection: 'row' },
42
+ h(Text, { color: '#7B2FFF' }, '\u2502'),
43
+ h(Text, {
44
+ color: '#555',
45
+ bold: false,
46
+ wrap: 'truncate-end',
47
+ }, ' ' + (collapsed ? '\u25B6' : '\u25BC') + ' Thought' + (timeStr ? ' (' + timeStr + ')' : '')),
48
+ ),
49
+ collapsed
50
+ ? null
51
+ : h(Box, { flexDirection: 'column', paddingLeft: 2 },
52
+ items.map((tr, i) => {
53
+ const isLast = i === items.length - 1;
54
+ const prefix = isLast ? '\u2514\u2500' : '\u251C\u2500';
55
+ const icon = tr.status === 'failed' ? '\u2716' : '\u2714';
56
+ const col = tr.status === 'failed' ? '#FF4455' : '#00FF88';
57
+ const td = tr.duration ? ' ' + tr.duration + 'ms' : '';
58
+ return h(Box, { key: tr.execId || i, flexDirection: 'column' },
59
+ h(Box, { flexDirection: 'row' },
60
+ h(Text, { color: '#555' }, ' ' + prefix + ' '),
61
+ h(Text, { color: col }, icon + ' ' + tr.name + td),
62
+ ),
63
+ tr.content && tr.content.length < 200
64
+ ? h(Box, { paddingLeft: 4 },
65
+ h(Text, { color: '#AAA', wrap: 'wrap' }, String(tr.content).slice(0, tw() - 10))
66
+ )
67
+ : null
68
+ );
69
+ })
70
+ )
54
71
  );
55
72
  }
56
73
 
57
74
  export function MessageBubble({ msg, isStreaming }) {
58
- const w = termWidth();
75
+ const w = tw();
59
76
 
60
77
  if (msg.role === 'user') {
78
+ const lines = String(msg.content).split('\n');
61
79
  return h(Box, { flexDirection: 'column', marginBottom: 1, marginTop: 1 },
62
80
  h(Box, { flexDirection: 'row' },
63
81
  h(Text, { color: '#FF6B6B', bold: true }, '\u276F'),
64
82
  h(Text, { color: '#FF6B6B', bold: true }, ' YOU '),
65
83
  h(Text, { color: '#9B59FF' }, '\u2500'.repeat(Math.max(0, w - 14))),
66
84
  ),
67
- h(UserContent, { text: String(msg.content) })
85
+ h(Box, { flexDirection: 'column', backgroundColor: '#1A0A2E' },
86
+ lines.map((line, i) =>
87
+ h(Box, { key: i, flexDirection: 'row', backgroundColor: '#1A0A2E' },
88
+ h(Text, { color: '#9B59FF', backgroundColor: '#1A0A2E' }, '\u2502'),
89
+ h(Text, { color: '#C39BD3', backgroundColor: '#1A0A2E', wrap: 'wrap' }, ' ' + (line || ' '))
90
+ )
91
+ )
92
+ )
68
93
  );
69
94
  }
70
95
 
71
96
  if (msg.role === 'assistant') {
72
97
  const parts = useMemo(() => parseContent(msg.content), [msg.content]);
98
+ const hasToolResults = msg.toolResults && msg.toolResults.length > 0;
99
+
73
100
  return h(Box, { flexDirection: 'column', marginBottom: 1 },
74
101
  h(Box, { flexDirection: 'row' },
75
102
  msg.streaming
@@ -81,20 +108,29 @@ export function MessageBubble({ msg, isStreaming }) {
81
108
  ? h(Text, { color: '#00D4FF' }, ' \u25CF streaming')
82
109
  : null
83
110
  ),
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
- })
111
+ hasToolResults
112
+ ? h(ThoughtBlock, { toolResults: msg.toolResults, duration: msg.duration })
113
+ : null,
114
+ parts.length > 0 && parts[0].content
115
+ ? parts.map((part, i) =>
116
+ part.type === 'code'
117
+ ? h(CodeBlock, { key: i, code: part.code, language: part.lang, termWidth: w })
118
+ : h(TextContent, { key: i, text: part.content })
119
+ )
120
+ : null,
121
+ msg.duration && !hasToolResults
122
+ ? h(Box, { flexDirection: 'row' },
123
+ h(Text, { color: '#7B2FFF' }, '\u2502'),
124
+ h(Text, { color: '#555' }, ' + Response: ' + (msg.duration < 1000 ? msg.duration + 'ms' : (msg.duration / 1000).toFixed(1) + 's'))
125
+ )
126
+ : null
90
127
  );
91
128
  }
92
129
 
93
130
  if (msg.role === 'tool') {
94
131
  return h(ToolCard, {
95
132
  name: msg.toolName || 'tool',
96
- args: msg.args,
97
- status: msg.status || (msg.error ? 'failed' : 'completed'),
133
+ status: msg.error ? 'failed' : 'completed',
98
134
  duration: msg.duration,
99
135
  result: msg.content,
100
136
  error: msg.error,
@@ -1,107 +1,111 @@
1
- import React, { useState, useRef, useCallback, useMemo } from 'react';
1
+ import React, { useState, useRef, useMemo } from 'react';
2
2
  import { Box, Text, useInput } from 'ink';
3
3
  import { MessageBubble } from './MessageBubble.js';
4
- import { LoadingIndicator } from './LoadingIndicator.js';
4
+ import { ToolCard } from './ToolCard.js';
5
5
  const { createElement: h } = React;
6
6
 
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;
12
-
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
- }
7
+ function ToolExecutionTree({ executions }) {
8
+ if (!executions || executions.length === 0) return null;
9
+ return h(Box, { flexDirection: 'column', marginLeft: 2, marginY: 1 },
10
+ executions.map((exec, i) => {
11
+ const isLast = i === executions.length - 1;
12
+ const prefix = isLast ? '\u2514\u2500' : '\u251C\u2500';
13
+ const icon = exec.status === 'completed' ? '\u2714' :
14
+ exec.status === 'failed' ? '\u2716' : '\u25CF';
15
+ const color = exec.status === 'completed' ? '#00FF88' :
16
+ exec.status === 'failed' ? '#FF4455' : '#00D4FF';
17
+ const timeStr = exec.duration ? ' ' + exec.duration + 'ms' : '';
18
+ return h(Box, { key: exec.execId, flexDirection: 'column' },
19
+ h(Box, { flexDirection: 'row' },
20
+ h(Text, { color: '#555' }, ' ' + prefix + ' '),
21
+ h(Text, { color }, ' ' + icon + ' ' + exec.name + timeStr),
22
+ ),
23
+ exec.status === 'completed' && exec.result
24
+ ? h(Box, { paddingLeft: 6 },
25
+ h(Text, { color: '#AAA', wrap: 'wrap' },
26
+ String(exec.result).slice(0, termTrunc())
27
+ )
28
+ )
29
+ : null,
30
+ exec.status === 'running'
31
+ ? h(Box, { paddingLeft: 6 },
32
+ h(Text, { color: '#555' }, 'running...')
33
+ )
34
+ : null,
35
+ );
36
+ })
37
+ );
38
+ }
41
39
 
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
- }
40
+ const termTrunc = () => Math.min(process.stdout.columns || 80, 120);
51
41
 
52
- function pageUp() {
53
- setScrollOffset(s => {
54
- const next = Math.max(0, s - maxVisible);
55
- setUserScrolled(true);
56
- return next;
57
- });
58
- }
42
+ function AgentStatusLine({ agentStatus, executions }) {
43
+ if (!agentStatus && (!executions || executions.length === 0)) return null;
44
+ return h(Box, { flexDirection: 'column', marginLeft: 2, marginBottom: 1 },
45
+ agentStatus
46
+ ? h(Box, { flexDirection: 'row' },
47
+ h(Text, { color: '#7B2FFF' }, '\u2502'),
48
+ h(Text, { color: '#00D4FF' }, ' \u25CF ' + agentStatus)
49
+ )
50
+ : null,
51
+ executions && executions.length > 0
52
+ ? h(ToolExecutionTree, { executions })
53
+ : null,
54
+ );
55
+ }
59
56
 
60
- function goHome() {
61
- setScrollOffset(0);
62
- setUserScrolled(true);
63
- }
57
+ export function MessageList({ messages, thinking, streamContent, agentStatus, toolExecutions }) {
58
+ const [scrollOffset, setScrollOffset] = useState(0);
59
+ const [userScrolled, setUserScrolled] = useState(false);
60
+ const termHeight = process.stdout.rows ? process.stdout.rows - 6 : 20;
61
+ const maxVisible = Math.max(termHeight - 3, 5);
64
62
 
65
- function goEnd() {
66
- const max = Math.max(0, totalItems - maxVisible);
67
- setScrollOffset(max);
68
- setUserScrolled(false);
69
- }
63
+ const extraEntries = (thinking || streamContent) ? 1 : 0;
64
+ const totalItems = messages.length + extraEntries;
65
+ const maxOffset = Math.max(0, totalItems - maxVisible);
70
66
 
71
67
  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();
68
+ if (key.upArrow && !key.ctrl) { setScrollOffset(s => { const n = Math.max(0, s - 1); if (n < maxOffset) setUserScrolled(true); return n; }); }
69
+ if (key.downArrow && !key.ctrl) { setScrollOffset(s => { const n = Math.min(maxOffset, s + 1); if (n >= maxOffset) setUserScrolled(false); return n; }); }
70
+ if (key.pageUp) { setScrollOffset(s => { const n = Math.max(0, s - maxVisible); setUserScrolled(true); return n; }); }
71
+ if (key.pageDown) { setScrollOffset(s => { const n = Math.min(maxOffset, s + maxVisible); if (n >= maxOffset) setUserScrolled(false); else setUserScrolled(true); return n; }); }
72
+ if (key.home) { setScrollOffset(0); setUserScrolled(true); }
73
+ if (key.end) { setScrollOffset(maxOffset); setUserScrolled(false); }
78
74
  });
79
75
 
80
- if (atBottom && !userScrolled) {
81
- const max = Math.max(0, totalItems - maxVisible);
82
- if (scrollOffset !== max) setScrollOffset(max);
76
+ if (!userScrolled && scrollOffset < maxOffset) {
77
+ setScrollOffset(maxOffset);
83
78
  }
84
79
 
85
- const visibleMessages = messages.slice(visibleStart, visibleEnd);
80
+ const visibleEnd = Math.min(totalItems, scrollOffset + maxVisible);
81
+ const visibleStart = Math.max(0, visibleEnd - maxVisible);
82
+ const visibleMsgs = messages.slice(visibleStart, visibleEnd);
83
+ const showStreaming = (thinking || streamContent) && visibleEnd >= totalItems - 1;
86
84
 
87
85
  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
- })
86
+ h(Box, { flexDirection: 'column', flexGrow: 1 },
87
+ visibleMsgs.map(msg =>
88
+ h(MessageBubble, { key: msg.id, msg, isStreaming: false })
95
89
  ),
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' })
90
+ showStreaming
91
+ ? h(Box, { flexDirection: 'column' },
92
+ h(AgentStatusLine, { agentStatus, executions: toolExecutions }),
93
+ streamContent
94
+ ? h(MessageBubble, {
95
+ key: 'streaming',
96
+ msg: { id: 'stream', role: 'assistant', content: streamContent, streaming: true },
97
+ isStreaming: true
98
+ })
99
+ : h(Box, { marginLeft: 2, flexDirection: 'row' },
100
+ h(Text, { color: '#7B2FFF' }, '\u2502'),
101
+ h(Text, { color: '#00D4FF' }, ' \u25CF thinking...')
102
+ )
103
+ )
100
104
  : null
101
105
  ),
102
106
  userScrolled
103
107
  ? h(Box, { flexDirection: 'row', justifyContent: 'center' },
104
- h(Text, { color: '#00D4FF', bold: true, backgroundColor: '#1A1A2E' },
108
+ h(Text, { color: '#00D4FF', bold: true, backgroundColor: '#1A1A2E', wrap: 'truncate-end' },
105
109
  ' \u25B2 Jump to latest (End) '
106
110
  )
107
111
  )
@@ -1,6 +1,15 @@
1
+ function readWithTimeout(reader, timeoutMs) {
2
+ return Promise.race([
3
+ reader.read(),
4
+ new Promise((_, reject) =>
5
+ setTimeout(() => reject(new Error('timeout')), timeoutMs)
6
+ ),
7
+ ]);
8
+ }
9
+
1
10
  export async function* streamResponse(endpoint, body, apiKey, extraHeaders = {}) {
2
11
  const controller = new AbortController();
3
- const timeoutId = setTimeout(() => controller.abort(), 30000);
12
+ const fetchTimeoutId = setTimeout(() => controller.abort(), 15000);
4
13
 
5
14
  try {
6
15
  const res = await fetch(endpoint, {
@@ -14,7 +23,7 @@ export async function* streamResponse(endpoint, body, apiKey, extraHeaders = {})
14
23
  signal: controller.signal,
15
24
  });
16
25
 
17
- clearTimeout(timeoutId);
26
+ clearTimeout(fetchTimeoutId);
18
27
 
19
28
  if (!res.ok) {
20
29
  const text = await res.text();
@@ -26,13 +35,34 @@ export async function* streamResponse(endpoint, body, apiKey, extraHeaders = {})
26
35
  const decoder = new TextDecoder();
27
36
  let buffer = '';
28
37
  const toolCallsMap = new Map();
38
+ let hasContent = false;
39
+ let idleTimeout = 15000;
29
40
 
30
41
  while (true) {
31
- const { done, value } = await reader.read();
42
+ let result;
43
+ try {
44
+ result = await readWithTimeout(reader, idleTimeout);
45
+ } catch (err) {
46
+ if (err.message === 'timeout') {
47
+ if (hasContent) {
48
+ yield { type: 'timeout', message: 'Stream idle for ' + idleTimeout + 'ms' };
49
+ }
50
+ const toolCalls = Array.from(toolCallsMap.values());
51
+ if (toolCalls.length > 0) {
52
+ yield { type: 'tool_calls', calls: toolCalls };
53
+ }
54
+ return;
55
+ }
56
+ throw err;
57
+ }
58
+
59
+ const { done, value } = result;
32
60
  if (done) break;
61
+
33
62
  buffer += decoder.decode(value, { stream: true });
34
63
  const lines = buffer.split('\n');
35
64
  buffer = lines.pop() || '';
65
+
36
66
  for (const line of lines) {
37
67
  if (line.startsWith('data: ')) {
38
68
  const data = line.slice(6).trim();
@@ -51,6 +81,8 @@ export async function* streamResponse(endpoint, body, apiKey, extraHeaders = {})
51
81
  const delta = choice.delta;
52
82
 
53
83
  if (delta?.content) {
84
+ hasContent = true;
85
+ idleTimeout = 15000;
54
86
  yield { type: 'token', content: delta.content };
55
87
  }
56
88
 
@@ -88,6 +120,7 @@ export async function* streamResponse(endpoint, body, apiKey, extraHeaders = {})
88
120
  yield { type: 'tool_calls', calls: toolCalls };
89
121
  }
90
122
  } finally {
91
- clearTimeout(timeoutId);
123
+ clearTimeout(fetchTimeoutId);
124
+ try { controller.abort(); } catch {}
92
125
  }
93
126
  }