clarity-ai 5.1.0 → 6.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": "5.1.0",
4
- "description": "Premium OpenCode-style terminal AI agent — streaming, tools, multiline composer, virtual scroll, code blocks",
3
+ "version": "6.0.0",
4
+ "description": "Premium OpenCode-style terminal AI agent — 24-bit TrueColor theme, 8s timeout recovery, virtual scroll, inline tool trees, collapsible thought cards",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "clarity": "bin/clarity.js"
@@ -15,11 +15,17 @@
15
15
  "dependencies": {
16
16
  "ink": "^5",
17
17
  "react": "^18",
18
- "ink-text-input": "^6.0.0",
19
18
  "ink-spinner": "^5",
20
19
  "ink-big-text": "^2",
21
20
  "ink-gradient": "^3",
22
21
  "marked": "^12",
23
- "cli-highlight": "^2"
22
+ "cli-highlight": "^2",
23
+ "chalk": "^5",
24
+ "ansi-escapes": "^7",
25
+ "cli-cursor": "^5",
26
+ "wrap-ansi": "^9",
27
+ "strip-ansi": "^7",
28
+ "string-width": "^7",
29
+ "picocolors": "^1"
24
30
  }
25
31
  }
package/src/chat.js CHANGED
@@ -87,6 +87,7 @@ async function processStream(provider, model, history, agentMode, setState, onSt
87
87
  let buffer = '';
88
88
  let toolCallsData = null;
89
89
  let thoughtTime = Date.now();
90
+ let timedOut = false;
90
91
 
91
92
  try {
92
93
  for await (const event of stream) {
@@ -95,12 +96,13 @@ async function processStream(provider, model, history, agentMode, setState, onSt
95
96
  onStreamContent(buffer);
96
97
  setState(s => ({ ...s, agentStatus: 'Writing response...' }));
97
98
  } else if (event.type === 'tool_calls') {
98
- toolCallsData = event.calls;
99
+ if (!timedOut) toolCallsData = event.calls;
99
100
  } else if (event.type === 'done') {
100
101
  } else if (event.type === 'timeout') {
102
+ timedOut = true;
101
103
  setState(s => ({
102
- ...s, agentStatus: 'Stream stalled, completing...',
103
- messages: [...s.messages, { id: nextId(), role: 'system', content: 'Stream timeoutresponse may be incomplete' }],
104
+ ...s, agentStatus: 'Stalled recovering...',
105
+ messages: [...s.messages, { id: nextId(), role: 'system', content: 'Response timed out showing partial result.' }],
104
106
  }));
105
107
  } else if (event.type === 'error') {
106
108
  setState(s => ({ ...s, thinking: false, streamContent: '', agentStatus: '', toolExecutions: [], thoughtTimer: null }));
@@ -136,6 +138,12 @@ async function processStream(provider, model, history, agentMode, setState, onSt
136
138
  onStreamContent('');
137
139
  }
138
140
 
141
+ if (timedOut) {
142
+ setState(s => ({ ...s, thinking: false, toolExecutions: [], thoughtTimer: null }));
143
+ onStreamContent('');
144
+ return;
145
+ }
146
+
139
147
  if (toolCallsData && toolCallsData.length > 0 && agentMode) {
140
148
  const execs = toolCallsData.map(tc => ({
141
149
  execId: nextExecId(),
@@ -1,13 +1,23 @@
1
1
  import React from 'react';
2
- import { Box } from 'ink';
3
- import BigText from 'ink-big-text';
2
+ import { Box, Text } from 'ink';
4
3
  import Gradient from 'ink-gradient';
4
+ import BigText from 'ink-big-text';
5
5
  const { createElement: h } = React;
6
6
 
7
7
  export function Banner() {
8
- return h(Box, { justifyContent: 'center', marginBottom: 0 },
9
- h(Gradient, { name: 'cristal' },
10
- h(BigText, { text: 'CLARITY', font: 'block' })
11
- )
8
+ return h(Box, { flexDirection: 'column', alignItems: 'center', marginTop: 1, marginBottom: 1 },
9
+ h(Gradient, { name: 'summer' },
10
+ h(BigText, { text: 'CLARITY', font: 'chrome', letterSpacing: 1 })
11
+ ),
12
+ h(Box, { flexDirection: 'row', gap: 1 },
13
+ h(Text, { color: '#FF6B6B' }, '\u25C9'),
14
+ h(Text, { color: '#555' }, 'premium terminal AI'),
15
+ h(Text, { color: '#555' }, '\u00B7'),
16
+ h(Text, { color: '#00FF88' }, 'agent mode'),
17
+ h(Text, { color: '#555' }, '\u00B7'),
18
+ h(Text, { color: '#00D4FF' }, 'Ctrl+P commands'),
19
+ h(Text, { color: '#FF6B6B' }, '\u25C9'),
20
+ ),
21
+ h(Text, { color: '#2A2A2A' }, '\u2501'.repeat(Math.min(process.stdout.columns || 80, 60))),
12
22
  );
13
23
  }
@@ -1,5 +1,6 @@
1
1
  import React, { useMemo } from 'react';
2
2
  import { Box, Text } from 'ink';
3
+ import { theme } from '../config/theme.js';
3
4
  const { createElement: h } = React;
4
5
 
5
6
  const LANG_COLORS = {
@@ -11,26 +12,24 @@ const LANG_COLORS = {
11
12
  json: '#292929', yaml: '#CB171E', md: '#083FA1', sql: '#E38C00',
12
13
  };
13
14
 
14
- export function CodeBlock({ code, language, termWidth }) {
15
+ export function CodeBlock({ code, language }) {
15
16
  const lang = language || 'code';
16
17
  const lines = useMemo(() => String(code).split('\n'), [code]);
17
18
  const langColor = LANG_COLORS[lang] || '#555';
18
- const lineNumWidth = String(lines.length).length;
19
+ const lnW = String(lines.length).length;
19
20
 
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
- )
21
+ return h(Box, { flexDirection: 'column', marginY: 1, marginLeft: 0 },
22
+ h(Box, { flexDirection: 'row', backgroundColor: theme.codeBg },
23
+ h(Text, { color: langColor, bold: true, backgroundColor: '#1C1C1C' }, ' ' + lang + ' '),
24
+ h(Text, { color: theme.textMuted, backgroundColor: '#1C1C1C' }, String(lines.length) + ' lines '),
26
25
  ),
27
- h(Box, { flexDirection: 'column', backgroundColor: '#0D1117', paddingY: 0 },
26
+ h(Box, { flexDirection: 'column', backgroundColor: theme.codeBg },
28
27
  lines.map((line, i) =>
29
- h(Box, { key: i, flexDirection: 'row' },
30
- h(Text, { color: '#555', backgroundColor: '#0D1117' },
31
- ' ' + String(i + 1).padStart(lineNumWidth) + ' '
28
+ h(Box, { key: i, flexDirection: 'row', backgroundColor: theme.codeBg },
29
+ h(Text, { color: theme.textMuted, backgroundColor: theme.codeBg },
30
+ ' ' + String(i + 1).padStart(lnW) + ' '
32
31
  ),
33
- h(Text, { color: '#C9D1D9', backgroundColor: '#0D1117', wrap: 'truncate-end' },
32
+ h(Text, { color: '#C9D1D9', backgroundColor: theme.codeBg, wrap: 'truncate-end' },
34
33
  line || ' '
35
34
  )
36
35
  )
@@ -1,5 +1,6 @@
1
1
  import React, { useState } from 'react';
2
2
  import { Box, Text, useInput } from 'ink';
3
+ import { theme } from '../config/theme.js';
3
4
  const { createElement: h } = React;
4
5
 
5
6
  const COMMANDS = [
@@ -15,32 +16,50 @@ const COMMANDS = [
15
16
  ];
16
17
 
17
18
  export function CommandPicker({ query, onSelect, onClose }) {
19
+ const [search, setSearch] = useState('');
20
+ const [idx, setIdx] = useState(0);
21
+
18
22
  const filtered = COMMANDS.filter(c =>
19
- c.name.includes(query) || c.desc.toLowerCase().includes(query.toLowerCase())
23
+ c.name.includes(search) || c.desc.toLowerCase().includes(search.toLowerCase())
20
24
  );
21
- const [idx, setIdx] = useState(0);
22
25
 
23
26
  useInput((input, key) => {
24
27
  if (key.upArrow) setIdx(i => Math.max(0, i - 1));
25
28
  if (key.downArrow) setIdx(i => Math.min(filtered.length - 1, i + 1));
26
- if (key.return) onSelect(filtered[idx].name);
29
+ if (key.return) onSelect(filtered[idx]?.name || '');
27
30
  if (key.escape) onClose();
31
+ if (key.backspace) setSearch(s => s.slice(0, -1));
32
+ else if (input && !key.ctrl && !key.meta) setSearch(s => s + input);
28
33
  });
29
34
 
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' }, ''),
35
+ const tw = process.stdout.columns || 80;
36
+ const boxWidth = Math.min(tw - 4, 50);
37
+
38
+ return h(Box, { flexDirection: 'column', width: boxWidth },
39
+ h(Box, { flexDirection: 'row', marginBottom: 1, gap: 1 },
40
+ h(Text, { color: theme.textMuted }, '\u2315'),
41
+ h(Text, { color: search ? theme.text : theme.textMuted }, search || 'type to filter...'),
42
+ ),
33
43
  filtered.map((cmd, i) =>
34
- h(Box, { key: cmd.name, flexDirection: 'row', gap: 2 },
44
+ h(Box, {
45
+ key: cmd.name,
46
+ flexDirection: 'row',
47
+ backgroundColor: i === idx ? theme.selectionBg : undefined,
48
+ width: boxWidth,
49
+ },
35
50
  h(Text, {
36
- color: i === idx ? '#FF6B6B' : '#F0F0F0',
51
+ color: i === idx ? theme.selectionText : theme.text,
37
52
  bold: i === idx,
38
- backgroundColor: i === idx ? '#2A2A2A' : undefined,
39
- }, ' ' + cmd.name),
40
- h(Text, { color: '#555' }, cmd.desc)
53
+ backgroundColor: i === idx ? theme.selectionBg : undefined,
54
+ wrap: 'truncate-end',
55
+ }, ' ' + cmd.name.padEnd(16)),
56
+ h(Text, {
57
+ color: i === idx ? theme.selectionText : theme.textDim,
58
+ backgroundColor: i === idx ? theme.selectionBg : undefined,
59
+ wrap: 'truncate-end',
60
+ }, cmd.desc)
41
61
  )
42
62
  ),
43
- h(Text, { color: '#333' }, ''),
44
- h(Text, { color: '#555' }, ' \u2191\u2193 navigate \u23CE select Esc close')
63
+ h(Text, { color: theme.textMuted }, ' \u2191\u2193 nav \u23CE select Esc close')
45
64
  );
46
65
  }
@@ -1,152 +1,94 @@
1
- import React, { useState, useRef, useCallback } from 'react';
1
+ import React, { useState, useRef } from 'react';
2
2
  import { Box, Text, useInput } from 'ink';
3
+ import { theme } from '../config/theme.js';
3
4
  const { createElement: h } = React;
4
5
  const w = () => process.stdout.columns || 80;
5
6
 
6
7
  export function Composer({ provider, model, agentMode, thinking, onSlash, onSubmit }) {
7
8
  const [lines, setLines] = useState(['']);
8
9
  const [cursorLine, setCursorLine] = useState(0);
9
- const [cursorCol, setCursorCol] = useState(0);
10
- const inputRef = useRef({ lines: [''], line: 0, col: 0 });
10
+ const buf = useRef({ lines: [''], line: 0, col: 0 });
11
11
  const displayName = provider + '/' + model;
12
12
 
13
13
  useInput((input, key) => {
14
14
  if (thinking) return;
15
- const state = inputRef.current;
15
+ const s = buf.current;
16
16
 
17
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);
18
+ const text = s.lines.join('\n');
19
+ s.lines = ['']; s.line = 0; s.col = 0;
20
+ setLines(['']); setCursorLine(0);
25
21
  if (text.trim()) onSubmit(text);
26
22
  return;
27
23
  }
28
24
 
29
25
  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);
26
+ const rest = s.lines[s.line].slice(s.col);
27
+ s.lines[s.line] = s.lines[s.line].slice(0, s.col);
28
+ s.lines.splice(s.line + 1, 0, rest);
29
+ s.line++; s.col = 0;
30
+ setLines([...s.lines]); setCursorLine(s.line);
39
31
  return;
40
32
  }
41
33
 
42
34
  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--;
35
+ if (s.col > 0) {
36
+ s.lines[s.line] = s.lines[s.line].slice(0, s.col - 1) + s.lines[s.line].slice(s.col);
37
+ s.col--;
38
+ } else if (s.line > 0) {
39
+ s.col = s.lines[s.line - 1].length;
40
+ s.lines[s.line - 1] += s.lines[s.line];
41
+ s.lines.splice(s.line, 1);
42
+ s.line--;
53
43
  }
54
- setLines([...state.lines]);
55
- setCursorLine(state.line);
56
- setCursorCol(state.col);
44
+ setLines([...s.lines]); setCursorLine(s.line); setCursorLine(s.col);
57
45
  return;
58
46
  }
59
47
 
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
- }
48
+ if (key.upArrow && s.line > 0) { s.line--; s.col = Math.min(s.col, s.lines[s.line].length); setCursorLine(s.line); setCursorLine(s.col); return; }
49
+ if (key.downArrow && s.line < s.lines.length - 1) { s.line++; s.col = Math.min(s.col, s.lines[s.line].length); setCursorLine(s.line); setCursorLine(s.col); return; }
50
+ if (key.leftArrow) { if (s.col > 0) s.col--; else if (s.line > 0) { s.line--; s.col = s.lines[s.line].length; } setCursorLine(s.line); setCursorLine(s.col); return; }
51
+ if (key.rightArrow) { if (s.col < s.lines[s.line].length) s.col++; else if (s.line < s.lines.length - 1) { s.line++; s.col = 0; } setCursorLine(s.line); setCursorLine(s.col); return; }
103
52
 
104
53
  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);
54
+ if (input === '/' && s.lines.length === 1 && s.lines[0] === '') { onSlash?.(); return; }
55
+ s.lines[s.line] = s.lines[s.line].slice(0, s.col) + input + s.lines[s.line].slice(s.col);
56
+ s.col++;
57
+ setLines([...s.lines]); setCursorLine(s.line); setCursorLine(s.col);
117
58
  }
118
59
  });
119
60
 
120
- const currentText = lines.join('\n');
121
- const dispLines = currentText ? currentText.split('\n') : [''];
61
+ const dispLines = lines.join('\n') ? lines : [''];
122
62
  const height = Math.min(dispLines.length, 5);
123
- const blankLines = height - dispLines.length;
124
63
 
125
- return h(Box, { flexDirection: 'column', flexShrink: 0 },
126
- h(Box, { flexDirection: 'column', backgroundColor: '#0A0A0A', borderStyle: 'round', borderColor: '#2D2D2D' },
64
+ return h(Box, { flexDirection: 'column', flexShrink: 0, borderStyle: 'round', borderColor: theme.border },
65
+ h(Box, { flexDirection: 'column' },
127
66
  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 || ' ')),
67
+ h(Box, { key: i, flexDirection: 'row', backgroundColor: theme.surface },
68
+ h(Text, { color: theme.accent, backgroundColor: theme.surface },
69
+ i === cursorLine ? '\u276F' : ' '
70
+ ),
71
+ h(Text, { color: theme.text, backgroundColor: theme.surface, wrap: 'wrap' },
72
+ ' ' + (line || ' ')
73
+ ),
131
74
  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' }, ' ')
75
+ ? h(Text, { color: theme.info, backgroundColor: theme.surface }, '\u258C')
76
+ : h(Text, { backgroundColor: theme.surface }, ' ')
140
77
  )
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
78
  )
79
+ ),
80
+ h(Box, { flexDirection: 'row', gap: 1, paddingX: 1, backgroundColor: theme.surfaceAlt },
81
+ h(Text, { color: theme.textMuted }, '\u2502'),
82
+ h(Text, { color: theme.info }, '\u25C9 ' + displayName),
83
+ h(Text, { color: theme.textMuted }, '\u00B7'),
84
+ h(Text, { color: agentMode ? theme.success : theme.textMuted },
85
+ agentMode ? '\u25C9 agent' : '\u25CB agent'
86
+ ),
87
+ h(Text, { color: theme.textMuted }, '\u00B7'),
88
+ h(Text, { color: theme.textDim }, 'Ctrl+P'),
89
+ thinking
90
+ ? h(Text, { color: theme.warning }, ' \u25CF thinking')
91
+ : null,
150
92
  )
151
93
  );
152
94
  }
@@ -1,11 +1,14 @@
1
1
  import React from 'react';
2
2
  import { Box, Text } from 'ink';
3
3
  import Spinner from 'ink-spinner';
4
+ import { theme } from '../config/theme.js';
4
5
  const { createElement: h } = React;
5
6
 
6
7
  export function LoadingIndicator({ label }) {
7
- return h(Box, { paddingLeft: 2, marginBottom: 1 },
8
- h(Text, { color: 'cyan' }, h(Spinner, { type: 'dots' })),
9
- h(Text, { color: 'gray' }, ' ' + (label || 'Thinking'))
8
+ return h(Box, { flexDirection: 'row', marginLeft: 0, marginY: 1, backgroundColor: theme.surface },
9
+ h(Text, { color: theme.info, backgroundColor: theme.surface }, '\u25B6'),
10
+ h(Text, { color: theme.info, backgroundColor: theme.surface }, ' '),
11
+ h(Text, { color: theme.info, backgroundColor: theme.surface }, h(Spinner, { type: 'dots' })),
12
+ h(Text, { color: theme.textDim, backgroundColor: theme.surface }, ' ' + (label || 'Thinking'))
10
13
  );
11
14
  }
@@ -1,8 +1,7 @@
1
1
  import React, { useMemo, useState } from 'react';
2
2
  import { Box, Text } from 'ink';
3
3
  import { CodeBlock } from './CodeBlock.js';
4
- import { ThinkingBlock } from './ThinkingBlock.js';
5
- import { ToolCard } from './ToolCard.js';
4
+ import { theme } from '../config/theme.js';
6
5
  const { createElement: h } = React;
7
6
  const tw = () => process.stdout.columns || 80;
8
7
 
@@ -20,13 +19,35 @@ function parseContent(text) {
20
19
  return parts.length ? parts : [{ type: 'text', content: text }];
21
20
  }
22
21
 
23
- function TextContent({ text, color }) {
22
+ function TextSegment({ text, textColor, bgColor, borderColor }) {
24
23
  const lines = text.split('\n');
25
- return h(Box, { flexDirection: 'column' },
24
+ const bc = borderColor || theme.purple;
25
+ const bg = bgColor || theme.surface;
26
+ return h(Box, { flexDirection: 'column', backgroundColor: bg },
26
27
  lines.map((line, i) =>
27
- h(Box, { key: i, flexDirection: 'row' },
28
- h(Text, { color: color || '#7B2FFF' }, '\u2502'),
29
- h(Text, { color: '#E0E0E0', wrap: 'wrap' }, ' ' + (line || ' '))
28
+ h(Box, { key: i, flexDirection: 'row', backgroundColor: bg },
29
+ h(Text, { color: bc, backgroundColor: bg }, '\u25B6'),
30
+ h(Text, { color: textColor || theme.text, backgroundColor: bg, wrap: 'wrap' }, ' ' + (line || ' '))
31
+ )
32
+ )
33
+ );
34
+ }
35
+
36
+ function UserCard({ text }) {
37
+ const lines = text.split('\n');
38
+ return h(Box, { flexDirection: 'column', marginBottom: 1, marginTop: 1, backgroundColor: theme.userBg },
39
+ h(Box, { flexDirection: 'row', backgroundColor: theme.userBg },
40
+ h(Text, { color: theme.accent, bold: true, backgroundColor: theme.userBg }, '\u276F YOU'),
41
+ h(Text, { color: theme.border, backgroundColor: theme.userBg },
42
+ ' ' + '\u2501'.repeat(Math.max(0, tw() - 12))
43
+ ),
44
+ ),
45
+ h(Box, { flexDirection: 'column', backgroundColor: theme.userBg },
46
+ lines.map((line, i) =>
47
+ h(Box, { key: i, flexDirection: 'row', backgroundColor: theme.userBg },
48
+ h(Text, { color: theme.userBorder, backgroundColor: theme.userBg }, '\u25B6'),
49
+ h(Text, { color: theme.text, backgroundColor: theme.userBg, wrap: 'wrap' }, ' ' + (line || ' '))
50
+ )
30
51
  )
31
52
  )
32
53
  );
@@ -35,34 +56,39 @@ function TextContent({ text, color }) {
35
56
  function ThoughtBlock({ toolResults, duration }) {
36
57
  const [collapsed, setCollapsed] = useState(true);
37
58
  const items = toolResults || [];
38
- const timeStr = duration ? (duration < 1000 ? duration + 'ms' : (duration / 1000).toFixed(1) + 's') : '';
59
+ const timeStr = duration
60
+ ? (duration < 1000 ? duration + 'ms' : (duration / 1000).toFixed(1) + 's')
61
+ : '';
39
62
 
40
63
  return h(Box, { flexDirection: 'column', marginY: 0 },
41
- h(Box, { flexDirection: 'row' },
42
- h(Text, { color: '#7B2FFF' }, '\u2502'),
64
+ h(Box, { flexDirection: 'row', backgroundColor: theme.surfaceAlt },
65
+ h(Text, { color: theme.purple, backgroundColor: theme.surfaceAlt }, '\u25B6'),
43
66
  h(Text, {
44
- color: '#555',
45
- bold: false,
46
- wrap: 'truncate-end',
67
+ color: theme.textDim,
68
+ backgroundColor: theme.surfaceAlt,
47
69
  }, ' ' + (collapsed ? '\u25B6' : '\u25BC') + ' Thought' + (timeStr ? ' (' + timeStr + ')' : '')),
48
70
  ),
49
71
  collapsed
50
72
  ? null
51
- : h(Box, { flexDirection: 'column', paddingLeft: 2 },
73
+ : h(Box, { flexDirection: 'column', paddingLeft: 1, backgroundColor: theme.thinkingBg },
52
74
  items.map((tr, i) => {
53
75
  const isLast = i === items.length - 1;
54
- const prefix = isLast ? '\u2514\u2500' : '\u251C\u2500';
76
+ const prefix = isLast ? '\u2570' : '\u256D';
77
+ const conn = isLast ? ' ' : '\u2502';
55
78
  const icon = tr.status === 'failed' ? '\u2716' : '\u2714';
56
- const col = tr.status === 'failed' ? '#FF4455' : '#00FF88';
79
+ const col = tr.status === 'failed' ? theme.toolFailed : theme.toolSuccess;
57
80
  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),
81
+ return h(Box, { key: tr.execId || i, flexDirection: 'column', backgroundColor: theme.thinkingBg },
82
+ h(Box, { flexDirection: 'row', backgroundColor: theme.thinkingBg },
83
+ h(Text, { color: theme.textMuted, backgroundColor: theme.thinkingBg }, ' ' + prefix + '\u2500 '),
84
+ h(Text, { color: col, backgroundColor: theme.thinkingBg }, icon + ' ' + tr.name + td),
62
85
  ),
63
86
  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))
87
+ ? h(Box, { flexDirection: 'row', backgroundColor: theme.thinkingBg },
88
+ h(Text, { color: theme.textMuted, backgroundColor: theme.thinkingBg }, ' ' + conn + ' '),
89
+ h(Text, { color: theme.textDim, backgroundColor: theme.thinkingBg, wrap: 'wrap' },
90
+ String(tr.content).slice(0, tw() - 10)
91
+ )
66
92
  )
67
93
  : null
68
94
  );
@@ -75,37 +101,24 @@ export function MessageBubble({ msg, isStreaming }) {
75
101
  const w = tw();
76
102
 
77
103
  if (msg.role === 'user') {
78
- const lines = String(msg.content).split('\n');
79
- return h(Box, { flexDirection: 'column', marginBottom: 1, marginTop: 1 },
80
- h(Box, { flexDirection: 'row' },
81
- h(Text, { color: '#FF6B6B', bold: true }, '\u276F'),
82
- h(Text, { color: '#FF6B6B', bold: true }, ' YOU '),
83
- h(Text, { color: '#9B59FF' }, '\u2500'.repeat(Math.max(0, w - 14))),
84
- ),
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
- )
93
- );
104
+ return h(UserCard, { text: msg.content });
94
105
  }
95
106
 
96
107
  if (msg.role === 'assistant') {
97
108
  const parts = useMemo(() => parseContent(msg.content), [msg.content]);
98
109
  const hasToolResults = msg.toolResults && msg.toolResults.length > 0;
99
110
 
100
- return h(Box, { flexDirection: 'column', marginBottom: 1 },
101
- h(Box, { flexDirection: 'row' },
111
+ return h(Box, { flexDirection: 'column', marginBottom: 1, backgroundColor: theme.surface },
112
+ h(Box, { flexDirection: 'row', backgroundColor: theme.surface },
102
113
  msg.streaming
103
- ? h(Text, { color: '#7B2FFF' }, '\u25CF')
104
- : h(Text, { color: '#7B2FFF', bold: true }, '\u25C6'),
105
- h(Text, { color: '#7B2FFF', bold: true }, ' CLARITY '),
106
- h(Text, { color: '#555' }, '\u2500'.repeat(Math.max(0, w - 17))),
114
+ ? h(Text, { color: theme.info, backgroundColor: theme.surface }, '\u25CF')
115
+ : h(Text, { color: theme.purple, bold: true, backgroundColor: theme.surface }, '\u25C9'),
116
+ h(Text, { color: theme.purple, bold: true, backgroundColor: theme.surface }, ' CLARITY '),
117
+ h(Text, { color: theme.border, backgroundColor: theme.surface },
118
+ '\u2501'.repeat(Math.max(0, w - 20))
119
+ ),
107
120
  isStreaming
108
- ? h(Text, { color: '#00D4FF' }, ' \u25CF streaming')
121
+ ? h(Text, { color: theme.info, backgroundColor: theme.surface }, ' \u25CF')
109
122
  : null
110
123
  ),
111
124
  hasToolResults
@@ -114,51 +127,49 @@ export function MessageBubble({ msg, isStreaming }) {
114
127
  parts.length > 0 && parts[0].content
115
128
  ? parts.map((part, i) =>
116
129
  part.type === 'code'
117
- ? h(CodeBlock, { key: i, code: part.code, language: part.lang, termWidth: w })
118
- : h(TextContent, { key: i, text: part.content })
130
+ ? h(CodeBlock, { key: i, code: part.code, language: part.lang })
131
+ : h(TextSegment, { key: i, text: part.content, borderColor: theme.purple, bgColor: theme.surface })
119
132
  )
120
133
  : null,
121
134
  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'))
135
+ ? h(Box, { flexDirection: 'row', backgroundColor: theme.surface },
136
+ h(Text, { color: theme.purple, backgroundColor: theme.surface }, '\u25B6'),
137
+ h(Text, { color: theme.textDim, backgroundColor: theme.surface },
138
+ ' + Response: ' + (msg.duration < 1000 ? msg.duration + 'ms' : (msg.duration / 1000).toFixed(1) + 's')
139
+ )
125
140
  )
126
141
  : null
127
142
  );
128
143
  }
129
144
 
130
145
  if (msg.role === 'tool') {
131
- return h(ToolCard, {
132
- name: msg.toolName || 'tool',
133
- status: msg.error ? 'failed' : 'completed',
134
- duration: msg.duration,
135
- result: msg.content,
136
- error: msg.error,
137
- });
146
+ return h(Box, { flexDirection: 'column', marginY: 1, backgroundColor: theme.surfaceAlt, borderStyle: 'round', borderColor: msg.error ? theme.error : theme.success },
147
+ h(Box, { flexDirection: 'row', gap: 1, paddingX: 1 },
148
+ h(Text, { color: msg.error ? theme.toolFailed : theme.toolSuccess },
149
+ msg.error ? '\u2716 ' + (msg.toolName || 'tool') : '\u2714 ' + (msg.toolName || 'tool')
150
+ ),
151
+ msg.duration ? h(Text, { color: theme.textMuted }, msg.duration + 'ms') : null,
152
+ ),
153
+ msg.content
154
+ ? h(Box, { paddingX: 1 },
155
+ h(Text, { color: theme.textDim, wrap: 'wrap' }, String(msg.content).slice(0, w - 6))
156
+ )
157
+ : null
158
+ );
138
159
  }
139
160
 
140
161
  if (msg.role === 'error') {
141
- const display = String(msg.content).slice(0, w - 6);
142
- return h(Box, { flexDirection: 'row', gap: 1, marginY: 1, paddingLeft: 2 },
143
- h(Text, { color: '#FF4455' }, '\u2716'),
144
- h(Text, { color: '#FF4455', wrap: 'wrap' }, display)
162
+ return h(Box, { flexDirection: 'column', marginY: 1, backgroundColor: theme.surfaceAlt, borderStyle: 'round', borderColor: theme.error, paddingX: 1 },
163
+ h(Text, { color: theme.error }, '\u2716 ' + String(msg.content).slice(0, w - 6))
145
164
  );
146
165
  }
147
166
 
148
167
  if (msg.role === 'system') {
149
168
  return h(Box, { flexDirection: 'row', gap: 1, marginY: 1, paddingLeft: 2 },
150
- h(Text, { color: '#00FF88' }, '\u2714'),
151
- h(Text, { color: '#00FF88', wrap: 'wrap' }, String(msg.content))
169
+ h(Text, { color: theme.success }, '\u25C9'),
170
+ h(Text, { color: theme.success, wrap: 'wrap' }, String(msg.content))
152
171
  );
153
172
  }
154
173
 
155
- if (msg.role === 'thinking') {
156
- return h(ThinkingBlock, {
157
- content: msg.content,
158
- duration: msg.duration,
159
- depth: msg.depth || 0,
160
- });
161
- }
162
-
163
174
  return null;
164
175
  }
@@ -1,65 +1,68 @@
1
- import React, { useState, useRef, useMemo } from 'react';
1
+ import React, { useState, useRef, useEffect } from 'react';
2
2
  import { Box, Text, useInput } from 'ink';
3
3
  import { MessageBubble } from './MessageBubble.js';
4
- import { ToolCard } from './ToolCard.js';
4
+ import { theme } from '../config/theme.js';
5
5
  const { createElement: h } = React;
6
6
 
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
- }
7
+ function AgentProgress({ agentStatus, toolExecutions }) {
8
+ if (!agentStatus && (!toolExecutions || toolExecutions.length === 0)) return null;
39
9
 
40
- const termTrunc = () => Math.min(process.stdout.columns || 80, 120);
41
-
42
- function AgentStatusLine({ agentStatus, executions }) {
43
- if (!agentStatus && (!executions || executions.length === 0)) return null;
44
- return h(Box, { flexDirection: 'column', marginLeft: 2, marginBottom: 1 },
10
+ return h(Box, { flexDirection: 'column', marginLeft: 0, marginY: 1, backgroundColor: theme.surfaceAlt },
45
11
  agentStatus
46
- ? h(Box, { flexDirection: 'row' },
47
- h(Text, { color: '#7B2FFF' }, '\u2502'),
48
- h(Text, { color: '#00D4FF' }, ' \u25CF ' + agentStatus)
12
+ ? h(Box, { flexDirection: 'row', backgroundColor: theme.surfaceAlt },
13
+ h(Text, { color: theme.info, backgroundColor: theme.surfaceAlt }, '\u25B6'),
14
+ h(Text, { color: theme.info, backgroundColor: theme.surfaceAlt }, ' \u25CF ' + agentStatus)
49
15
  )
50
16
  : null,
51
- executions && executions.length > 0
52
- ? h(ToolExecutionTree, { executions })
53
- : null,
17
+ toolExecutions && toolExecutions.length > 0
18
+ ? h(Box, { flexDirection: 'column', backgroundColor: theme.surfaceAlt },
19
+ toolExecutions.map((exec, i) => {
20
+ const isLast = i === toolExecutions.length - 1;
21
+ const pre = isLast ? '\u2570' : '\u256D';
22
+ const icon = exec.status === 'running' ? '\u25CF' :
23
+ exec.status === 'failed' ? '\u2716' : '\u2714';
24
+ const col = exec.status === 'running' ? theme.toolRunning :
25
+ exec.status === 'failed' ? theme.toolFailed : theme.toolSuccess;
26
+ const ts = exec.duration ? ' ' + exec.duration + 'ms' : '';
27
+ return h(Box, { key: exec.execId, flexDirection: 'column', backgroundColor: theme.surfaceAlt },
28
+ h(Box, { flexDirection: 'row', backgroundColor: theme.surfaceAlt },
29
+ h(Text, { color: theme.textMuted, backgroundColor: theme.surfaceAlt }, ' ' + pre + '\u2500 '),
30
+ h(Text, { color: col, backgroundColor: theme.surfaceAlt }, icon + ' ' + exec.name + ts),
31
+ ),
32
+ exec.status === 'running'
33
+ ? h(Box, { flexDirection: 'row', backgroundColor: theme.surfaceAlt },
34
+ h(Text, { color: theme.textMuted, backgroundColor: theme.surfaceAlt }, ' running...')
35
+ )
36
+ : null,
37
+ exec.status === 'completed' && exec.result && String(exec.result).length < 150
38
+ ? h(Box, { flexDirection: 'row', backgroundColor: theme.surfaceAlt },
39
+ h(Text, { color: theme.textMuted, backgroundColor: theme.surfaceAlt }, ' '),
40
+ h(Text, { color: theme.textDim, backgroundColor: theme.surfaceAlt, wrap: 'wrap' },
41
+ String(exec.result).slice(0, process.stdout.columns - 10)
42
+ )
43
+ )
44
+ : null
45
+ );
46
+ })
47
+ )
48
+ : null
54
49
  );
55
50
  }
56
51
 
57
52
  export function MessageList({ messages, thinking, streamContent, agentStatus, toolExecutions }) {
58
53
  const [scrollOffset, setScrollOffset] = useState(0);
59
54
  const [userScrolled, setUserScrolled] = useState(false);
60
- const termHeight = process.stdout.rows ? process.stdout.rows - 6 : 20;
61
- const maxVisible = Math.max(termHeight - 3, 5);
55
+ const [termHeight, setTermHeight] = useState((process.stdout.rows || 30) - 8);
62
56
 
57
+ useEffect(() => {
58
+ function onResize() {
59
+ setTermHeight(Math.max(10, (process.stdout.rows || 30) - 8));
60
+ }
61
+ process.stdout.on('resize', onResize);
62
+ return () => { process.stdout.off('resize', onResize); };
63
+ }, []);
64
+
65
+ const maxVisible = Math.max(termHeight - 2, 5);
63
66
  const extraEntries = (thinking || streamContent) ? 1 : 0;
64
67
  const totalItems = messages.length + extraEntries;
65
68
  const maxOffset = Math.max(0, totalItems - maxVisible);
@@ -89,25 +92,27 @@ export function MessageList({ messages, thinking, streamContent, agentStatus, to
89
92
  ),
90
93
  showStreaming
91
94
  ? h(Box, { flexDirection: 'column' },
92
- h(AgentStatusLine, { agentStatus, executions: toolExecutions }),
95
+ h(AgentProgress, { agentStatus, toolExecutions }),
93
96
  streamContent
94
97
  ? h(MessageBubble, {
95
- key: 'streaming',
98
+ key: 'stream',
96
99
  msg: { id: 'stream', role: 'assistant', content: streamContent, streaming: true },
97
- isStreaming: true
100
+ isStreaming: true,
98
101
  })
99
- : h(Box, { marginLeft: 2, flexDirection: 'row' },
100
- h(Text, { color: '#7B2FFF' }, '\u2502'),
101
- h(Text, { color: '#00D4FF' }, ' \u25CF thinking...')
102
+ : h(Box, { flexDirection: 'row', backgroundColor: theme.surface },
103
+ h(Text, { color: theme.purple, backgroundColor: theme.surface }, '\u25B6'),
104
+ h(Text, { color: theme.info, backgroundColor: theme.surface }, ' \u25CF thinking...')
102
105
  )
103
106
  )
104
107
  : null
105
108
  ),
106
109
  userScrolled
107
110
  ? h(Box, { flexDirection: 'row', justifyContent: 'center' },
108
- h(Text, { color: '#00D4FF', bold: true, backgroundColor: '#1A1A2E', wrap: 'truncate-end' },
109
- ' \u25B2 Jump to latest (End) '
110
- )
111
+ h(Text, {
112
+ color: theme.selectionText,
113
+ bold: true,
114
+ backgroundColor: theme.selectionBg,
115
+ }, ' \u25B2 Jump to latest (End) ')
111
116
  )
112
117
  : null
113
118
  );
@@ -1,6 +1,7 @@
1
1
  import React, { useState, useMemo } from 'react';
2
2
  import { Box, Text, useInput } from 'ink';
3
3
  import { ALL_MODELS } from '../config/models.js';
4
+ import { theme } from '../config/theme.js';
4
5
  const { createElement: h } = React;
5
6
 
6
7
  export function ModelPicker({ onSelect, onClose }) {
@@ -19,10 +20,8 @@ export function ModelPicker({ onSelect, onClose }) {
19
20
  }
20
21
  const list = [];
21
22
  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
- });
23
+ list.push({ _header: provider, _provider: provider });
24
+ for (const m of models) list.push(m);
26
25
  }
27
26
  return list;
28
27
  }, [search]);
@@ -30,28 +29,49 @@ export function ModelPicker({ onSelect, onClose }) {
30
29
  useInput((input, key) => {
31
30
  if (key.upArrow) setIdx(i => Math.max(0, i - 1));
32
31
  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; }
32
+ if (key.return) { const m = flat[idx]; if (m && !m._header) onSelect(m.id); return; }
34
33
  if (key.escape) onClose();
35
34
  if (key.backspace) setSearch(s => s.slice(0, -1));
36
35
  else if (input && !key.ctrl && !key.meta) setSearch(s => s + input);
37
36
  });
38
37
 
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...'),
38
+ const tw = process.stdout.columns || 80;
39
+ const boxWidth = Math.min(tw - 4, 54);
40
+
41
+ return h(Box, { flexDirection: 'column', width: boxWidth },
42
+ h(Box, { flexDirection: 'row', marginBottom: 1, gap: 1 },
43
+ h(Text, { color: theme.textMuted }, '\u2315'),
44
+ h(Text, { color: search ? theme.text : theme.textMuted }, search || 'search models...'),
43
45
  ),
44
46
  flat.map((m, i) => {
45
47
  if (m._header) {
46
- return h(Text, { key: 'h-' + m._header, color: '#00D4FF', bold: true }, '\u25BC ' + m._header.toUpperCase());
48
+ return h(Text, {
49
+ key: 'h-' + m._provider,
50
+ color: theme.info,
51
+ bold: true,
52
+ backgroundColor: theme.surfaceAlt,
53
+ wrap: 'truncate-end',
54
+ }, ' \u25BC ' + m._provider.toUpperCase());
47
55
  }
48
- return h(Box, { key: m.id, flexDirection: 'row', gap: 2 },
56
+ const isSel = i === idx;
57
+ return h(Box, {
58
+ key: m.id,
59
+ flexDirection: 'row',
60
+ backgroundColor: isSel ? theme.selectionBg : undefined,
61
+ width: boxWidth,
62
+ },
49
63
  h(Text, {
50
- color: i === idx ? '#FF6B6B' : '#F0F0F0',
51
- bold: i === idx,
52
- backgroundColor: i === idx ? '#1A1A2E' : undefined,
64
+ color: isSel ? theme.selectionText : theme.text,
65
+ bold: isSel,
66
+ backgroundColor: isSel ? theme.selectionBg : undefined,
67
+ wrap: 'truncate-end',
53
68
  }, ' ' + m.label),
54
- m.badge ? h(Text, { color: '#555' }, '[' + m.badge + ']') : null
69
+ m.badge
70
+ ? h(Text, {
71
+ color: isSel ? theme.selectionText : theme.textMuted,
72
+ backgroundColor: isSel ? theme.selectionBg : undefined,
73
+ }, ' [' + m.badge + ']')
74
+ : null
55
75
  );
56
76
  })
57
77
  );
@@ -1,28 +1,27 @@
1
1
  import React, { useState } from 'react';
2
2
  import { Box, Text } from 'ink';
3
+ import { theme } from '../config/theme.js';
3
4
  const { createElement: h } = React;
4
5
 
5
6
  export function ThinkingBlock({ content, duration, depth = 0 }) {
6
7
  const [expanded, setExpanded] = useState(false);
7
8
  const lines = String(content || '').split('\n');
8
9
  const indent = depth * 2;
10
+ const timeStr = duration ? ' (' + duration + 'ms)' : '';
9
11
 
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),
12
+ return h(Box, { flexDirection: 'column', marginLeft: indent, marginY: 1, backgroundColor: theme.thinkingBg },
13
+ h(Box, { flexDirection: 'row', backgroundColor: theme.thinkingBg },
14
+ h(Text, { color: theme.purple, bold: true, backgroundColor: theme.thinkingBg },
15
+ (expanded ? '\u25BC' : '\u25B6') + ' Thinking' + timeStr
16
+ ),
20
17
  ),
21
18
  expanded
22
- ? h(Box, { flexDirection: 'column', paddingLeft: 2,
23
- borderStyle: 'single', borderColor: '#2D2D2D' },
19
+ ? h(Box, { flexDirection: 'column', paddingLeft: 1, backgroundColor: theme.thinkingBg },
24
20
  lines.map((line, i) =>
25
- h(Text, { key: i, color: '#A78BFA' }, ' ' + line)
21
+ h(Box, { key: i, flexDirection: 'row', backgroundColor: theme.thinkingBg },
22
+ h(Text, { color: theme.textMuted, backgroundColor: theme.thinkingBg }, '\u2502'),
23
+ h(Text, { color: theme.purpleDim, backgroundColor: theme.thinkingBg, wrap: 'wrap' }, ' ' + line)
24
+ )
26
25
  )
27
26
  )
28
27
  : null
@@ -1,41 +1,30 @@
1
1
  import React from 'react';
2
2
  import { Box, Text } from 'ink';
3
+ import { theme } from '../config/theme.js';
3
4
  const { createElement: h } = React;
4
5
 
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
6
  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) : '';
7
+ const col = status === 'running' ? theme.toolRunning :
8
+ status === 'failed' ? theme.toolFailed : theme.toolSuccess;
9
+ const icon = status === 'running' ? '\u25CF' :
10
+ status === 'failed' ? '\u2716' : '\u2714';
11
+ const argsStr = args ? JSON.stringify(args).slice(0, 60) : '';
23
12
  const timeStr = duration ? ' ' + duration + 'ms' : '';
24
13
 
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,
14
+ return h(Box, { flexDirection: 'column', marginY: 1, backgroundColor: theme.surfaceAlt, borderStyle: 'round', borderColor: col },
15
+ h(Box, { flexDirection: 'row', gap: 1, paddingX: 1 },
16
+ h(Text, { color: col }, icon + ' ' + name),
17
+ argsStr ? h(Text, { color: theme.textMuted }, argsStr) : null,
18
+ timeStr ? h(Text, { color: theme.textMuted }, timeStr) : null,
30
19
  ),
31
20
  status === 'completed' && result
32
- ? h(Box, { paddingLeft: 2 },
33
- h(Text, { color: '#AAA' }, String(result).slice(0, 200))
21
+ ? h(Box, { paddingX: 1 },
22
+ h(Text, { color: theme.textDim, wrap: 'wrap' }, String(result).slice(0, 200))
34
23
  )
35
24
  : null,
36
25
  status === 'failed' && error
37
- ? h(Box, { paddingLeft: 2 },
38
- h(Text, { color: '#FF4455' }, String(error).slice(0, 200))
26
+ ? h(Box, { paddingX: 1 },
27
+ h(Text, { color: theme.error, wrap: 'wrap' }, String(error).slice(0, 200))
39
28
  )
40
29
  : null,
41
30
  );
@@ -0,0 +1,57 @@
1
+ export const theme = {
2
+ bg: '#0A0A0A',
3
+ surface: '#111111',
4
+ surfaceAlt: '#1A1A1A',
5
+ border: '#2A2A2A',
6
+ borderLight: '#333333',
7
+
8
+ accent: '#FF6B6B',
9
+ accentDim: '#CC4444',
10
+ orange: '#FF7A00',
11
+ orangeDim: '#AA5500',
12
+
13
+ success: '#00FF88',
14
+ successDim: '#00AA55',
15
+ warning: '#FFD700',
16
+ warningDim: '#AA8800',
17
+ error: '#FF4455',
18
+ errorDim: '#AA2233',
19
+
20
+ info: '#00D4FF',
21
+ infoDim: '#0088AA',
22
+ purple: '#7B2FFF',
23
+ purpleDim: '#4A1AAA',
24
+
25
+ text: '#E8E8E8',
26
+ textDim: '#888888',
27
+ textMuted: '#555555',
28
+
29
+ userBg: '#1A0A2E',
30
+ userBorder: '#4A1AAA',
31
+ assistantBg: '#0D1117',
32
+ codeBg: '#0D1117',
33
+
34
+ selectionBg: '#FF6B6B',
35
+ selectionText: '#0A0A0A',
36
+ hoverBg: '#1A1A2E',
37
+
38
+ thinkingBg: '#0D0D1A',
39
+ thinkingBorder: '#4A1AAA',
40
+
41
+ toolRunning: '#00D4FF',
42
+ toolSuccess: '#00FF88',
43
+ toolFailed: '#FF4455',
44
+
45
+ gradientStart: '#FF6B6B',
46
+ gradientEnd: '#7B2FFF',
47
+ };
48
+
49
+ export function hexToRgb(hex) {
50
+ const val = parseInt(hex.slice(1), 16);
51
+ return { r: (val >> 16) & 255, g: (val >> 8) & 255, b: val & 255 };
52
+ }
53
+
54
+ export function dimColor(hex, amount = 0.5) {
55
+ const { r, g, b } = hexToRgb(hex);
56
+ return `#${[r,g,b].map(c => Math.round(c * amount).toString(16).padStart(2,'0')).join('')}`;
57
+ }
@@ -1,15 +1,17 @@
1
+ const READ_TIMEOUT = 8000;
2
+
1
3
  function readWithTimeout(reader, timeoutMs) {
2
4
  return Promise.race([
3
5
  reader.read(),
4
6
  new Promise((_, reject) =>
5
- setTimeout(() => reject(new Error('timeout')), timeoutMs)
7
+ setTimeout(() => reject(new Error('stream_timeout')), timeoutMs)
6
8
  ),
7
9
  ]);
8
10
  }
9
11
 
10
12
  export async function* streamResponse(endpoint, body, apiKey, extraHeaders = {}) {
11
13
  const controller = new AbortController();
12
- const fetchTimeoutId = setTimeout(() => controller.abort(), 15000);
14
+ const fetchTimeout = setTimeout(() => controller.abort(), READ_TIMEOUT);
13
15
 
14
16
  try {
15
17
  const res = await fetch(endpoint, {
@@ -23,7 +25,7 @@ export async function* streamResponse(endpoint, body, apiKey, extraHeaders = {})
23
25
  signal: controller.signal,
24
26
  });
25
27
 
26
- clearTimeout(fetchTimeoutId);
28
+ clearTimeout(fetchTimeout);
27
29
 
28
30
  if (!res.ok) {
29
31
  const text = await res.text();
@@ -36,21 +38,16 @@ export async function* streamResponse(endpoint, body, apiKey, extraHeaders = {})
36
38
  let buffer = '';
37
39
  const toolCallsMap = new Map();
38
40
  let hasContent = false;
39
- let idleTimeout = 15000;
40
41
 
41
42
  while (true) {
42
43
  let result;
43
44
  try {
44
- result = await readWithTimeout(reader, idleTimeout);
45
+ result = await readWithTimeout(reader, READ_TIMEOUT);
45
46
  } 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
- }
47
+ if (err.message === 'stream_timeout') {
48
+ yield { type: 'timeout' };
49
+ const calls = Array.from(toolCallsMap.values());
50
+ if (calls.length) yield { type: 'tool_calls', calls };
54
51
  return;
55
52
  }
56
53
  throw err;
@@ -64,63 +61,56 @@ export async function* streamResponse(endpoint, body, apiKey, extraHeaders = {})
64
61
  buffer = lines.pop() || '';
65
62
 
66
63
  for (const line of lines) {
67
- if (line.startsWith('data: ')) {
68
- const data = line.slice(6).trim();
69
- if (data === '[DONE]') {
70
- const toolCalls = Array.from(toolCallsMap.values());
71
- if (toolCalls.length > 0) {
72
- yield { type: 'tool_calls', calls: toolCalls };
73
- }
74
- return;
75
- }
76
- try {
77
- const json = JSON.parse(data);
78
- const choice = json.choices?.[0];
79
- if (!choice) continue;
80
-
81
- const delta = choice.delta;
64
+ if (!line.startsWith('data: ')) continue;
65
+ const data = line.slice(6).trim();
66
+ if (data === '[DONE]') {
67
+ const calls = Array.from(toolCallsMap.values());
68
+ if (calls.length) yield { type: 'tool_calls', calls };
69
+ return;
70
+ }
71
+ try {
72
+ const json = JSON.parse(data);
73
+ const choice = json.choices?.[0];
74
+ if (!choice) continue;
75
+ const delta = choice.delta;
82
76
 
83
- if (delta?.content) {
84
- hasContent = true;
85
- idleTimeout = 15000;
86
- yield { type: 'token', content: delta.content };
87
- }
77
+ if (delta?.content) {
78
+ hasContent = true;
79
+ yield { type: 'token', content: delta.content };
80
+ }
88
81
 
89
- if (delta?.tool_calls) {
90
- for (const tc of delta.tool_calls) {
91
- const idx = tc.index ?? 0;
92
- if (!toolCallsMap.has(idx)) {
93
- toolCallsMap.set(idx, {
94
- id: tc.id || 'call_' + Date.now() + '_' + idx,
95
- type: 'function',
96
- function: { name: '', arguments: '' },
97
- });
98
- }
99
- const existing = toolCallsMap.get(idx);
100
- if (tc.id) existing.id = tc.id;
101
- if (tc.function?.name) existing.function.name += tc.function.name;
102
- if (tc.function?.arguments) existing.function.arguments += tc.function.arguments;
82
+ if (delta?.tool_calls) {
83
+ for (const tc of delta.tool_calls) {
84
+ const idx = tc.index ?? 0;
85
+ if (!toolCallsMap.has(idx)) {
86
+ toolCallsMap.set(idx, {
87
+ id: tc.id || 'call_' + Date.now() + '_' + idx,
88
+ type: 'function',
89
+ function: { name: '', arguments: '' },
90
+ });
103
91
  }
92
+ const existing = toolCallsMap.get(idx);
93
+ if (tc.id) existing.id = tc.id;
94
+ if (tc.function?.name) existing.function.name += tc.function.name;
95
+ if (tc.function?.arguments) existing.function.arguments += tc.function.arguments;
104
96
  }
97
+ }
105
98
 
106
- if (choice.finish_reason) {
107
- const toolCalls = Array.from(toolCallsMap.values());
108
- if (toolCalls.length > 0 && choice.finish_reason === 'tool_calls') {
109
- yield { type: 'tool_calls', calls: toolCalls };
110
- }
111
- yield { type: 'done', reason: choice.finish_reason };
99
+ if (choice.finish_reason) {
100
+ const calls = Array.from(toolCallsMap.values());
101
+ if (calls.length && choice.finish_reason === 'tool_calls') {
102
+ yield { type: 'tool_calls', calls };
112
103
  }
113
- } catch {}
114
- }
104
+ yield { type: 'done', reason: choice.finish_reason };
105
+ }
106
+ } catch {}
115
107
  }
116
108
  }
117
109
 
118
- const toolCalls = Array.from(toolCallsMap.values());
119
- if (toolCalls.length > 0) {
120
- yield { type: 'tool_calls', calls: toolCalls };
121
- }
110
+ const calls = Array.from(toolCallsMap.values());
111
+ if (calls.length) yield { type: 'tool_calls', calls };
122
112
  } finally {
123
- clearTimeout(fetchTimeoutId);
113
+ clearTimeout(fetchTimeout);
124
114
  try { controller.abort(); } catch {}
125
115
  }
126
116
  }