clarity-ai 7.2.0 → 7.3.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/CHANGELOG.md CHANGED
@@ -2,6 +2,27 @@
2
2
 
3
3
  ---
4
4
 
5
+ ## 7.3.0 (2026-06-06)
6
+
7
+ ### Panel-driven TUI overhaul — structured frame layout with live thinking stream
8
+ - **Bordered TopPanel**: ASCII banner + status chip + hotkey row wrapped in `┌─┐│├─┤` frame. No more floating text lines. Status badge, hotkey hints, and gradient logo all share a single structured surface.
9
+ - **Anchored ComposerDock**: Box-drawn (`┌─┐│└─┘`) input panel hard-locked to bottom rows. Contains TextInput, provider/model/AGENT status line, and right-aligned `tab agents ctrl+p` hotkey hints. Never moves during streaming.
10
+ - **Live thinking stream engine**: Real-time `<think>...</think>` tag parser splits incoming token stream into:
11
+ - `reasoning` → collapsible low-contrast `#14141E` panel with dim gray text
12
+ - `display` → main chat viewport (cleaned, no reasoning noise)
13
+ - `Ctrl+T` toggles thinking panel visibility
14
+ - **ThinkingPanel component**: Bordered frame with chevron toggle, character count, and last 8 wrapped lines of reasoning text. Sits inside chat viewport without polluting the final output.
15
+ - **Box-drawing panel boundaries**: `┌ ─ ┐│├ ─ ┤└ ─ ┘` Unicode frames on all structural panels. Border color `#2A2A3A` for subtle but professional separation.
16
+ - **Orange selection accent**: `#FF9F43` full-width highlight bars with `#000000` black text on all active popup rows and status chips.
17
+
18
+ ## 7.2.1 (2026-06-06)
19
+
20
+ ### Critical hotfix: mouse bleed elimination, CLARITY branding, TrueColor textures
21
+ - **FIXED input corruption**: Removed XTerm mouse tracking (`\x1b[?1000h`) entirely. Raw `\x1b[<0;54;41M` sequences no longer leak into the chat text buffer. Termux soft keyboard now works reliably without raw-mode conflicts.
22
+ - **FIXED ASCII logo**: Replaced the figlet banner that rendered as "GLARITY" with a clean 5-line solid block matrix (`██████`) that unambiguously spells CLARITY. Three-tier sizing (5-line / 3-line compact / 1-line gradient).
23
+ - **TrueColor panel textures**: Every message block, streaming pane, and popup container now uses explicit `#1C1C1C` graphite backgrounds. No naked terminal defaults. Orange `#FF9F43` full-width selection bar with `#000000` black text on all active popup rows.
24
+ - **Stderr isolation maintained**: `console.log/error/warn` continue routing to `clarity-debug.log` to prevent stray backend traces from corrupting the UI layer.
25
+
5
26
  ## 7.2.0 (2026-06-06)
6
27
 
7
28
  ### Sticky floating overlay engine — touch selection, phase-driven layout, virtualized log
package/bin/clarity.js CHANGED
@@ -57,14 +57,14 @@ async function main() {
57
57
  logFile.write('[CLEANUP] CLARITY exiting\n');
58
58
  logFile.end();
59
59
  try { clear(); } catch {}
60
- process.stdout.write('\x1b[?1000l\x1b[?1006l\x1b[?25h\x1b[0m');
60
+ process.stdout.write('\x1b[?25h\x1b[0m');
61
61
  process.exit(0);
62
62
  }
63
63
 
64
64
  process.on('SIGINT', () => cleanup());
65
65
  process.on('SIGTERM', () => cleanup());
66
66
  process.on('exit', () => {
67
- process.stdout.write('\x1b[?1000l\x1b[?1006l\x1b[?25h\x1b[0m');
67
+ process.stdout.write('\x1b[?25h\x1b[0m');
68
68
  });
69
69
 
70
70
  await new Promise(() => {});
@@ -76,7 +76,7 @@ main().catch(err => {
76
76
  console.warn = originalWarn;
77
77
  logFile.write('[FATAL] ' + (err?.message || String(err)) + '\n');
78
78
  logFile.end();
79
- process.stdout.write('\x1b[?1000l\x1b[?1006l\x1b[?25h\x1b[0m');
79
+ process.stdout.write('\x1b[?25h\x1b[0m');
80
80
  console.error('\n\x1b[31mFatal error:\x1b[0m', err?.message || err);
81
81
  process.exit(1);
82
82
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clarity-ai",
3
- "version": "7.2.0",
3
+ "version": "7.3.0",
4
4
  "description": "CLARITY — terminal AI agent with local GGUF inference on HF Spaces",
5
5
  "type": "module",
6
6
  "bin": {
package/src/app.js CHANGED
@@ -2,17 +2,13 @@ import React, { useState, useCallback, useRef, useEffect } from 'react';
2
2
  import { Box, useStdout, useInput } from 'ink';
3
3
  import { createChatState, handleSend, handleCommand } from './chat.js';
4
4
  import { hex } from './config/theme.js';
5
- import { Banner } from './components/Banner.js';
6
- import { StatusBar } from './components/StatusBar.js';
7
- import { StreamView } from './components/StreamView.js';
8
- import { InputDock } from './components/InputDock.js';
5
+ import { TopPanel } from './components/TopPanel.js';
6
+ import { ChatViewport } from './components/ChatViewport.js';
7
+ import { ComposerDock } from './components/ComposerDock.js';
9
8
  import { SlashPopup } from './components/SlashPopup.js';
10
- import { Footer } from './components/Footer.js';
11
- import { useMouse } from './hooks/useMouse.js';
12
9
  const { createElement: h } = React;
13
10
 
14
11
  let abortController = null;
15
-
16
12
  export function getAbortController() { return abortController; }
17
13
  export function setAbortController(ac) { abortController = ac; }
18
14
  export function cancelStream() {
@@ -20,10 +16,15 @@ export function cancelStream() {
20
16
  }
21
17
 
22
18
  const DOCK_H = 4;
23
- const FOOTER_H = 2;
24
- const STATUS_H = 1;
19
+ const TOP_H_PRE = 2;
25
20
  const POPUP_W = 44;
26
21
 
22
+ const COMMAND_ITEMS = [
23
+ { name: '/keys' }, { name: '/model' }, { name: '/provider' },
24
+ { name: '/agent' }, { name: '/stop' }, { name: '/clear' },
25
+ { name: '/export' }, { name: '/help' }, { name: '/exit' },
26
+ ];
27
+
27
28
  function deriveStatus(state, sc) {
28
29
  if (!state.thinking) return 'idle';
29
30
  return sc ? 'streaming' : 'thinking';
@@ -33,14 +34,37 @@ function hasRealMessages(msgs) {
33
34
  return msgs.some(m => m.role === 'user' || m.role === 'assistant');
34
35
  }
35
36
 
37
+ function getFiltered(input) {
38
+ const q = input.replace(/^\//, '').toLowerCase();
39
+ if (!q) return COMMAND_ITEMS;
40
+ return COMMAND_ITEMS.filter(c => c.name.includes(q));
41
+ }
42
+
43
+ function parseStream(text) {
44
+ let reasoning = '';
45
+ let display = '';
46
+ let inThink = false;
47
+ let i = 0;
48
+ while (i < text.length) {
49
+ if (text.startsWith('<think>', i)) { inThink = true; i += 7; }
50
+ else if (text.startsWith('</think>', i)) { inThink = false; i += 8; }
51
+ else { if (inThink) reasoning += text[i]; else display += text[i]; i++; }
52
+ }
53
+ return { reasoning, display };
54
+ }
55
+
36
56
  export function App({ config }) {
37
57
  const { stdout } = useStdout();
38
58
  const [dims, setDims] = useState({ rows: stdout.rows || 30, cols: stdout.columns || 80 });
39
59
  const [state, setState] = useState(() => createChatState());
40
60
  const [streamContent, setStreamContent] = useState('');
61
+ const [streamReasoning, setStreamReasoning] = useState('');
62
+ const [streamDisplay, setStreamDisplay] = useState('');
41
63
  const [thinkingStart, setThinkingStart] = useState(null);
64
+ const [thinkingMs, setThinkingMs] = useState(0);
42
65
  const [input, setInput] = useState('');
43
66
  const [popupIdx, setPopupIdx] = useState(0);
67
+ const [showThinking, setShowThinking] = useState(true);
44
68
  const defaultModel = (config.model || '').replace(/^[^/]+\//, '') || 'llama-3.3-70b-versatile';
45
69
  const [model, setModel] = useState(defaultModel);
46
70
  const [provider, setProvider] = useState(config.provider || 'groq');
@@ -54,23 +78,36 @@ export function App({ config }) {
54
78
 
55
79
  const rows = dims.rows;
56
80
  const cols = dims.cols;
57
- const bannerH = cols < 50 ? 2 : 6;
81
+ const bannerH = cols < 50 ? 2 : (cols < 60 ? 3 : 5);
82
+ const topH = bannerH + TOP_H_PRE;
58
83
  const status = deriveStatus(state, streamContent);
59
84
  const isChat = hasRealMessages(state.messages);
60
85
  const cardW = Math.min(cols - 4, 56);
61
86
  const showPopup = input.startsWith('/') && status === 'idle';
62
- const availLines = Math.max(2, rows - bannerH - STATUS_H - DOCK_H - FOOTER_H - 2);
87
+ const availLines = Math.max(2, rows - topH - DOCK_H - 1);
63
88
 
64
89
  useEffect(() => {
65
- function onResize() { setDims({ r: process.stdout.rows || 30, c: process.stdout.columns || 80 }); }
90
+ function onResize() { setDims({ rows: process.stdout.rows || 30, cols: process.stdout.columns || 80 }); }
66
91
  process.stdout.on('resize', onResize);
67
92
  return () => process.stdout.removeListener('resize', onResize);
68
93
  }, []);
69
94
 
70
95
  useEffect(() => {
71
- if (state.thinking && !thinkingStart) setThinkingStart(Date.now());
72
- else if (!state.thinking) setThinkingStart(null);
73
- }, [state.thinking]);
96
+ if (state.thinking && !thinkingStart) {
97
+ setThinkingStart(Date.now());
98
+ const id = setInterval(() => setThinkingMs(Date.now() - thinkingStart), 100);
99
+ return () => clearInterval(id);
100
+ } else if (!state.thinking) {
101
+ setThinkingStart(null);
102
+ setThinkingMs(0);
103
+ }
104
+ }, [state.thinking, thinkingStart]);
105
+
106
+ useEffect(() => {
107
+ const { reasoning, display } = parseStream(streamContent || '');
108
+ setStreamReasoning(reasoning);
109
+ setStreamDisplay(display);
110
+ }, [streamContent]);
74
111
 
75
112
  const onSubmit = useCallback(async (val) => {
76
113
  if (val === '/exit') { process.exit(0); return; }
@@ -85,8 +122,8 @@ export function App({ config }) {
85
122
  }, []);
86
123
 
87
124
  const onCommand = useCallback(async (val) => {
88
- if (val.startsWith('/stop')) { cancelStream(); return; }
89
- if (val.startsWith('/exit')) { process.exit(0); return; }
125
+ if (val === '/stop') { cancelStream(); return; }
126
+ if (val === '/exit') { process.exit(0); return; }
90
127
  await handleCommand(val, stateRef.current, setState, setModel, setProvider, modelRef.current, providerRef.current);
91
128
  }, []);
92
129
 
@@ -99,86 +136,67 @@ export function App({ config }) {
99
136
  else onSubmit(t);
100
137
  }
101
138
 
102
- function handleInputChange(val) {
103
- setInput(val);
104
- if (val.startsWith('/')) setPopupIdx(0);
105
- }
139
+ function handleInputChange(val) { setInput(val); if (val.startsWith('/')) setPopupIdx(0); }
106
140
 
107
141
  function handlePopupSelect(cmd) {
108
- setInput('');
109
- setPopupIdx(0);
142
+ setInput(''); setPopupIdx(0);
110
143
  if (cmd === '/exit') process.exit(0);
111
- if (cmd === '/stop') { cancelStream(); return; }
112
144
  onCommand(cmd);
113
145
  }
114
146
 
115
147
  function closePopup() { setInput(''); setPopupIdx(0); }
116
148
 
117
149
  useInput((ch, key) => {
118
- if (!showPopup) return;
119
- if (key.escape) { closePopup(); return; }
120
- if (key.return) {
121
- const COMMANDS = [{ name: '/keys' }, { name: '/model' }, { name: '/provider' }, { name: '/agent' }, { name: '/stop' }, { name: '/clear' }, { name: '/export' }, { name: '/help' }, { name: '/exit' }];
122
- const q = input.replace(/^\//, '').toLowerCase();
123
- const filtered = q ? COMMANDS.filter(c => c.name.includes(q)) : COMMANDS;
124
- if (filtered[popupIdx]) handlePopupSelect(filtered[popupIdx].name);
125
- return;
126
- }
127
- if (key.upArrow) { setPopupIdx(i => Math.max(0, i - 1)); return; }
128
- if (key.downArrow) {
129
- const COMMANDS = [{ name: '/keys' }, { name: '/model' }, { name: '/provider' }, { name: '/agent' }, { name: '/stop' }, { name: '/clear' }, { name: '/export' }, { name: '/help' }, { name: '/exit' }];
130
- const q = input.replace(/^\//, '').toLowerCase();
131
- const filtered = q ? COMMANDS.filter(c => c.name.includes(q)) : COMMANDS;
132
- setPopupIdx(i => Math.min(filtered.length - 1, i + 1));
150
+ if (showPopup) {
151
+ if (key.escape) { closePopup(); return; }
152
+ if (key.return) { const f = getFiltered(input); if (f[popupIdx]) handlePopupSelect(f[popupIdx].name); return; }
153
+ if (key.upArrow) { setPopupIdx(i => Math.max(0, i - 1)); return; }
154
+ if (key.downArrow) { const f = getFiltered(input); setPopupIdx(i => Math.min(f.length - 1, i + 1)); return; }
133
155
  }
156
+ if (key.ctrl && key.t) { setShowThinking(p => !p); }
134
157
  });
135
158
 
136
- const handleClick = useCallback(({ col, row }) => {
137
- if (!showPopup) return;
138
- const popupLeft = Math.floor((cols - POPUP_W) / 2) + 1;
139
- const popupH = 1 + 9 + 1;
140
- const popupTop = rows - DOCK_H - FOOTER_H - popupH;
141
- if (col >= popupLeft && col < popupLeft + POPUP_W && row >= popupTop && row < popupTop + popupH) {
142
- const itemRow = row - popupTop - 1;
143
- const COMMANDS = [{ name: '/keys' }, { name: '/model' }, { name: '/provider' }, { name: '/agent' }, { name: '/stop' }, { name: '/clear' }, { name: '/export' }, { name: '/help' }, { name: '/exit' }];
144
- if (itemRow >= 0 && itemRow < COMMANDS.length) {
145
- handlePopupSelect(COMMANDS[itemRow].name);
146
- }
147
- }
148
- }, [showPopup, cols, rows]);
149
-
150
- useMouse(handleClick);
151
-
152
- const bottomArea = h(Box, { flexDirection: 'column', alignItems: 'center', width: '100%' },
159
+ const centerArea = h(Box, { flexDirection: 'column', alignItems: 'center', width: '100%' },
160
+ h(TopPanel, { cols, status, thinkingMs }),
161
+ isChat
162
+ ? h(Box, { flexGrow: 1, width: cardW, flexDirection: 'column', overflow: 'hidden' },
163
+ h(ChatViewport, {
164
+ messages: state.messages, streamContent, streamDisplay, reasoning: streamReasoning,
165
+ status, showThinking, onToggleThinking: () => setShowThinking(p => !p),
166
+ maxLines: availLines, width: cardW,
167
+ })
168
+ )
169
+ : null,
153
170
  showPopup
154
- ? h(Box, { position: 'absolute', bottom: DOCK_H + FOOTER_H, alignItems: 'center', width: '100%' },
155
- h(SlashPopup, { search: input, selectedIdx: popupIdx, onHover: setPopupIdx, width: POPUP_W })
171
+ ? h(Box, { position: 'absolute', bottom: DOCK_H + 1, alignItems: 'center', width: '100%' },
172
+ h(SlashPopup, { search: input, selectedIdx: popupIdx, width: POPUP_W })
156
173
  )
157
174
  : null,
158
- h(InputDock, {
175
+ h(ComposerDock, {
159
176
  width: cardW, provider, model, agentMode: state.agentMode, status,
160
177
  input, onInputChange: handleInputChange, onSubmit: handleSubmit,
161
178
  }),
162
- h(Footer, { cols })
163
179
  );
164
180
 
165
181
  if (!isChat) {
166
182
  return h(Box, { width: '100%', height: rows, flexDirection: 'column', alignItems: 'center', justifyContent: 'center', backgroundColor: hex.bg },
167
- h(Box, { flexDirection: 'column', alignItems: 'center', width: cardW },
168
- h(Banner, { cols }),
169
- h(StatusBar, { status, thinkingStart }),
170
- h(Box, { height: 1 }),
171
- bottomArea
183
+ h(Box, { flexDirection: 'column', alignItems: 'center', width: '100%' },
184
+ h(TopPanel, { cols, status, thinkingMs }),
185
+ h(Box, { flexGrow: 1 }),
186
+ showPopup
187
+ ? h(Box, { position: 'absolute', bottom: DOCK_H + 1, alignItems: 'center', width: '100%' },
188
+ h(SlashPopup, { search: input, selectedIdx: popupIdx, width: POPUP_W })
189
+ )
190
+ : null,
191
+ h(ComposerDock, {
192
+ width: cardW, provider, model, agentMode: state.agentMode, status,
193
+ input, onInputChange: handleInputChange, onSubmit: handleSubmit,
194
+ }),
172
195
  )
173
196
  );
174
197
  }
175
198
 
176
199
  return h(Box, { width: '100%', height: rows, flexDirection: 'column', alignItems: 'center', backgroundColor: hex.bg },
177
- h(Banner, { cols }),
178
- h(StatusBar, { status, thinkingStart }),
179
- h(Box, { flexGrow: 1, width: cardW, flexDirection: 'column', overflow: 'hidden' },
180
- h(StreamView, { messages: state.messages, streamContent, status, maxLines: availLines, width: cardW })
181
- ),
182
- bottomArea
200
+ centerArea
183
201
  );
184
202
  }
@@ -2,6 +2,7 @@ import React, { useMemo } from 'react';
2
2
  import { Box, Text } from 'ink';
3
3
  import { hex } from '../config/theme.js';
4
4
  import wrapAnsi from 'wrap-ansi';
5
+ import { ThinkingPanel } from './ThinkingPanel.js';
5
6
  const { createElement: h } = React;
6
7
 
7
8
  function lineCount(text, width) {
@@ -13,8 +14,6 @@ function measure(msg, width) {
13
14
  const cw = Math.max(4, width);
14
15
  if (msg.role === 'user') return 1 + lineCount(msg.content, cw);
15
16
  if (msg.role === 'assistant') return 1 + lineCount(msg.content, cw) + (msg.duration ? 1 : 0);
16
- if (msg.role === 'tool') return 1;
17
- if (msg.role === 'system' || msg.role === 'error') return 1;
18
17
  return 1;
19
18
  }
20
19
 
@@ -23,7 +22,7 @@ function wrap(text, width) {
23
22
  return wrapAnsi(String(text), Math.max(4, width), { trim: false, hard: true }).split('\n');
24
23
  }
25
24
 
26
- function MsgRole({ role }) {
25
+ function RoleIcon({ role }) {
27
26
  switch (role) {
28
27
  case 'user': return h(Text, { color: hex.orange, bold: true }, '\u276F ');
29
28
  case 'assistant': return h(Text, { color: hex.purple, bold: true }, '\u25C6 ');
@@ -36,20 +35,21 @@ function MsgRole({ role }) {
36
35
 
37
36
  function MsgBlock({ msg, width }) {
38
37
  const lines = useMemo(() => wrap(msg.content, width), [msg.content, width]);
38
+ const bg = msg.role === 'user' ? hex.surface : hex.cardBg;
39
39
 
40
- return h(Box, { flexDirection: 'column' },
41
- h(Box, { height: 1 },
42
- h(MsgRole, { role: msg.role }),
43
- h(Text, { color: hex.text }, lines[0] || '')
40
+ return h(Box, { flexDirection: 'column', backgroundColor: bg },
41
+ h(Box, { height: 1, backgroundColor: bg },
42
+ h(RoleIcon, { role: msg.role }),
43
+ h(Text, { color: hex.text, backgroundColor: bg }, lines[0] || '')
44
44
  ),
45
45
  lines.slice(1).map((l, i) =>
46
- h(Box, { key: i, height: 1 },
47
- h(Text, { color: hex.text }, ' ' + l)
46
+ h(Box, { key: i, height: 1, backgroundColor: bg },
47
+ h(Text, { color: hex.text, backgroundColor: bg }, ' ' + l)
48
48
  )
49
49
  ),
50
50
  msg.role === 'assistant' && msg.duration
51
- ? h(Box, { height: 1 },
52
- h(Text, { color: hex.textMuted },
51
+ ? h(Box, { height: 1, backgroundColor: bg },
52
+ h(Text, { color: hex.textMuted, backgroundColor: bg },
53
53
  ' ' + (msg.duration < 1000 ? msg.duration + 'ms' : (msg.duration / 1000).toFixed(1) + 's'))
54
54
  )
55
55
  : null
@@ -58,28 +58,30 @@ function MsgBlock({ msg, width }) {
58
58
 
59
59
  function StreamingBlock({ content, width }) {
60
60
  const lines = useMemo(() => wrap(content, width), [content, width]);
61
- return h(Box, { flexDirection: 'column' },
62
- h(Box, { height: 1 },
63
- h(Text, { color: hex.blue, bold: true }, '\u25CF '),
64
- h(Text, { color: hex.text }, lines[0] || '')
61
+ return h(Box, { flexDirection: 'column', backgroundColor: hex.cardBg },
62
+ h(Box, { height: 1, backgroundColor: hex.cardBg },
63
+ h(Text, { color: hex.blue, bold: true, backgroundColor: hex.cardBg }, '\u25CF '),
64
+ h(Text, { color: hex.text, backgroundColor: hex.cardBg }, lines[0] || '')
65
65
  ),
66
66
  lines.slice(1).map((l, i) =>
67
- h(Box, { key: i, height: 1 },
68
- h(Text, { color: hex.text }, ' ' + l)
67
+ h(Box, { key: i, height: 1, backgroundColor: hex.cardBg },
68
+ h(Text, { color: hex.text, backgroundColor: hex.cardBg }, ' ' + l)
69
69
  )
70
70
  )
71
71
  );
72
72
  }
73
73
 
74
- export function StreamView({ messages, streamContent, status, maxLines, width }) {
74
+ export function ChatViewport({ messages, streamContent, streamDisplay, reasoning, status, showThinking, onToggleThinking, maxLines, width }) {
75
75
  const cw = Math.max(4, width - 2);
76
76
 
77
+ const thinkingH = showThinking && reasoning ? Math.min(10, 3 + wrapAnsi(String(reasoning), cw, { trim: false, hard: true }).split('\n').length) : 0;
78
+
77
79
  const visible = useMemo(() => {
78
80
  if (messages.length === 0) {
79
81
  return [{ id: 'welcome', role: 'system', content: 'CLARITY AI ready \u00B7 /help for commands' }];
80
82
  }
81
83
 
82
- let avail = maxLines;
84
+ let avail = maxLines - thinkingH;
83
85
  const result = [];
84
86
  for (let i = messages.length - 1; i >= 0; i--) {
85
87
  const needed = measure(messages[i], cw);
@@ -93,8 +95,8 @@ export function StreamView({ messages, streamContent, status, maxLines, width })
93
95
  result.unshift(messages[i]);
94
96
  }
95
97
 
96
- if (streamContent && (status === 'streaming' || status === 'thinking')) {
97
- const streamNeeded = 1 + lineCount(streamContent, cw);
98
+ if (streamDisplay && (status === 'streaming' || status === 'thinking')) {
99
+ const streamNeeded = 1 + lineCount(streamDisplay, cw);
98
100
  while (streamNeeded > avail && result.length > 0) {
99
101
  avail += measure(result[0], cw);
100
102
  result.shift();
@@ -102,17 +104,18 @@ export function StreamView({ messages, streamContent, status, maxLines, width })
102
104
  }
103
105
 
104
106
  return result;
105
- }, [messages, streamContent, status, maxLines, cw]);
107
+ }, [messages, streamDisplay, status, maxLines, thinkingH, cw]);
106
108
 
107
- return h(Box, { flexDirection: 'column', width: '100%', paddingLeft: 1, paddingRight: 1 },
109
+ return h(Box, { flexDirection: 'column', width: '100%' },
108
110
  visible.map(m => h(MsgBlock, { key: m.id, msg: m, width: cw })),
109
- (status === 'streaming' || status === 'thinking') && streamContent
110
- ? h(StreamingBlock, { content: streamContent, width: cw })
111
+ (status === 'streaming' || status === 'thinking') && streamDisplay
112
+ ? h(StreamingBlock, { content: streamDisplay, width: cw })
111
113
  : null,
112
- status === 'thinking' && !streamContent
113
- ? h(Box, { height: 1 },
114
- h(Text, { color: hex.textMuted }, ' \u25CF processing...')
114
+ status === 'thinking' && !streamDisplay
115
+ ? h(Box, { height: 1, backgroundColor: hex.cardBg },
116
+ h(Text, { color: hex.textMuted, backgroundColor: hex.cardBg }, ' \u25CF processing...')
115
117
  )
116
118
  : null,
119
+ h(ThinkingPanel, { reasoning, visible: showThinking, width, onToggle: onToggleThinking }),
117
120
  );
118
121
  }
@@ -0,0 +1,39 @@
1
+ import React from 'react';
2
+ import { Box, Text } from 'ink';
3
+ import TextInput from 'ink-text-input';
4
+ import { hex } from '../config/theme.js';
5
+ const { createElement: h } = React;
6
+
7
+ function borderLine(w, left, mid, right) {
8
+ return left + mid.repeat(Math.max(0, w - 2)) + right;
9
+ }
10
+
11
+ export function ComposerDock({ width, provider, model, agentMode, status, input, onInputChange, onSubmit }) {
12
+ const isLocked = status !== 'idle';
13
+ const mShort = model.replace(/^[^/]+\//, '').slice(0, 16);
14
+ const innerW = Math.max(4, width - 4);
15
+ const statusText = (provider || 'groq') + ' \u00B7 ' + mShort + (agentMode ? ' \u00B7 AGENT' : '');
16
+
17
+ return h(Box, { flexDirection: 'column', width, backgroundColor: hex.cardBg },
18
+ h(Box, { height: 1 },
19
+ h(Text, { color: hex.border }, borderLine(width, '\u251C', '\u2500', '\u2524'))
20
+ ),
21
+ h(Box, { height: 1, paddingLeft: 2, paddingRight: 2, backgroundColor: hex.cardBg },
22
+ h(TextInput, {
23
+ value: input,
24
+ onChange: onInputChange,
25
+ onSubmit,
26
+ placeholder: 'Ask anything...',
27
+ focus: !isLocked,
28
+ })
29
+ ),
30
+ h(Box, { height: 1, paddingLeft: 2, paddingRight: 2, backgroundColor: hex.cardBg },
31
+ h(Text, { color: hex.textMuted, backgroundColor: hex.cardBg }, statusText),
32
+ h(Text, { color: hex.textMuted, backgroundColor: hex.cardBg },
33
+ ' '.repeat(Math.max(0, innerW - statusText.length)) + 'tab agents ctrl+p')
34
+ ),
35
+ h(Box, { height: 1 },
36
+ h(Text, { color: hex.border }, borderLine(width, '\u2514', '\u2500', '\u2518'))
37
+ ),
38
+ );
39
+ }
@@ -1,4 +1,4 @@
1
- import React, { useState, useMemo } from 'react';
1
+ import React, { useMemo } from 'react';
2
2
  import { Box, Text } from 'ink';
3
3
  import { hex } from '../config/theme.js';
4
4
  const { createElement: h } = React;
@@ -15,7 +15,7 @@ const COMMANDS = [
15
15
  { name: '/exit', desc: 'Exit CLARITY' },
16
16
  ];
17
17
 
18
- export function SlashPopup({ search, selectedIdx, onHover, width }) {
18
+ export function SlashPopup({ search, selectedIdx, width }) {
19
19
  const filtered = useMemo(() => {
20
20
  const q = search.replace(/^\//, '').toLowerCase();
21
21
  if (!q) return COMMANDS;
@@ -25,31 +25,33 @@ export function SlashPopup({ search, selectedIdx, onHover, width }) {
25
25
  }, [search]);
26
26
 
27
27
  const innerW = Math.max(10, width - 4);
28
- const itemLabel = (cmd, i) => {
28
+ const label = (cmd, i) => {
29
29
  const sel = i === selectedIdx;
30
- const label = ' ' + cmd.name + ' ' + cmd.desc;
31
- return label.length > innerW ? label.slice(0, innerW - 2) + '\u2026' : label;
30
+ const text = ' ' + cmd.name + ' ' + cmd.desc;
31
+ return text.length > innerW ? text.slice(0, innerW - 2) + '\u2026' : text;
32
32
  };
33
33
 
34
- return h(Box, { flexDirection: 'column', width, backgroundColor: hex.surface },
34
+ return h(Box, { flexDirection: 'column', width, backgroundColor: hex.cardBg },
35
35
  h(Box, { height: 1, backgroundColor: hex.surfaceAlt },
36
- h(Text, { color: hex.textMuted }, ' ' + (search || '/') + ' '.repeat(Math.max(0, innerW - (search || '/').length)))
36
+ h(Text, { color: hex.textMuted, backgroundColor: hex.surfaceAlt },
37
+ ' ' + (search || '/') + ' '.repeat(Math.max(0, innerW - (search || '/').length)))
37
38
  ),
38
39
  filtered.map((cmd, i) =>
39
40
  h(Box, {
40
41
  key: cmd.name,
41
42
  height: 1,
42
- backgroundColor: i === selectedIdx ? hex.orange : 'transparent',
43
+ backgroundColor: i === selectedIdx ? hex.orange : hex.cardBg,
43
44
  },
44
45
  h(Text, {
45
46
  color: i === selectedIdx ? '#000000' : hex.text,
46
47
  bold: i === selectedIdx,
47
- }, itemLabel(cmd, i))
48
+ backgroundColor: i === selectedIdx ? hex.orange : hex.cardBg,
49
+ }, label(cmd, i))
48
50
  )
49
51
  ),
50
52
  filtered.length === 0
51
- ? h(Box, { height: 1 },
52
- h(Text, { color: hex.textMuted }, ' No matching commands'))
53
+ ? h(Box, { height: 1, backgroundColor: hex.cardBg },
54
+ h(Text, { color: hex.textMuted, backgroundColor: hex.cardBg }, ' No matching commands'))
53
55
  : null,
54
56
  );
55
57
  }
@@ -0,0 +1,40 @@
1
+ import React from 'react';
2
+ import { Box, Text } from 'ink';
3
+ import { hex } from '../config/theme.js';
4
+ import wrapAnsi from 'wrap-ansi';
5
+ const { createElement: h } = React;
6
+
7
+ function borderLine(w, left, mid, right) {
8
+ return left + mid.repeat(Math.max(0, w - 2)) + right;
9
+ }
10
+
11
+ export function ThinkingPanel({ reasoning, visible, width, onToggle }) {
12
+ if (!visible || !reasoning) return null;
13
+
14
+ const innerW = Math.max(4, width - 4);
15
+ const wrapped = wrapAnsi(String(reasoning), innerW, { trim: false, hard: true });
16
+ const lines = wrapped.split('\n').slice(-8);
17
+ const panelH = Math.min(lines.length + 3, 10);
18
+
19
+ return h(Box, { flexDirection: 'column', width, backgroundColor: hex.thinkingBg },
20
+ h(Box, { height: 1 },
21
+ h(Text, { color: hex.border }, borderLine(width, '\u250C', '\u2500', '\u2510'))
22
+ ),
23
+ h(Box, { height: 1, backgroundColor: hex.thinkingBg },
24
+ h(Text, { color: hex.border, backgroundColor: hex.thinkingBg }, '\u2502'),
25
+ h(Text, { color: hex.thinkingText, italic: true, backgroundColor: hex.thinkingBg }, ' \u25BE thinking '),
26
+ h(Text, { color: hex.textMuted, backgroundColor: hex.thinkingBg }, '(' + reasoning.length + ' chars)'),
27
+ h(Text, { color: hex.border, backgroundColor: hex.thinkingBg }, ' '.repeat(Math.max(0, innerW - reasoning.length.toString().length - 18)) + '\u2502')
28
+ ),
29
+ lines.map((l, i) =>
30
+ h(Box, { key: i, height: 1, backgroundColor: hex.thinkingBg },
31
+ h(Text, { color: hex.border, backgroundColor: hex.thinkingBg }, '\u2502'),
32
+ h(Text, { color: hex.thinkingText, backgroundColor: hex.thinkingBg }, ' ' + l),
33
+ h(Text, { color: hex.border, backgroundColor: hex.thinkingBg }, ' '.repeat(Math.max(0, innerW - l.length)) + '\u2502')
34
+ )
35
+ ),
36
+ h(Box, { height: 1 },
37
+ h(Text, { color: hex.border }, borderLine(width, '\u2514', '\u2500', '\u2518'))
38
+ ),
39
+ );
40
+ }
@@ -0,0 +1,85 @@
1
+ import React from 'react';
2
+ import { Box, Text } from 'ink';
3
+ import { appGradient, hex } from '../config/theme.js';
4
+ const { createElement: h } = React;
5
+
6
+ const BANNER = [
7
+ '██████ ██ █████ ██████ ██ ████████ ██ ██',
8
+ '██ ██ ██ ██ ██ ██ ██ ██ ██ ██',
9
+ '██ ██ ███████ ██████ ██ ██ ████',
10
+ '██ ██ ██ ██ ██ ██ ██ ██ ██',
11
+ '██████ ███████ ██ ██ ██ ██ ██ ██ ██',
12
+ ];
13
+
14
+ const BANNER_COMPACT = [
15
+ '██████ ██ █████ ██████ ██ ████████ ██ ██',
16
+ '██ ██ ██ ██ ██ ██ ██ ██ ██ ██',
17
+ '██████ ███████ ██ ██ ██ ██ ██ ██ ██',
18
+ ];
19
+
20
+ function StatusChip({ status, thinkingMs }) {
21
+ if (status === 'idle') {
22
+ return h(Box, { backgroundColor: hex.surfaceAlt, paddingX: 1 },
23
+ h(Text, { color: hex.textMuted }, ' IDLE ')
24
+ );
25
+ }
26
+ if (status === 'thinking') {
27
+ return h(Box, { backgroundColor: hex.surfaceAlt, paddingX: 1 },
28
+ h(Text, { color: hex.orange }, ' \u25CF ' + thinkingMs + 'ms ')
29
+ );
30
+ }
31
+ return h(Box, { backgroundColor: hex.surfaceAlt, paddingX: 1 },
32
+ h(Text, { color: hex.blue }, ' \u25CF STREAMING ')
33
+ );
34
+ }
35
+
36
+ function borderLine(w, left, mid, right) {
37
+ return left + mid.repeat(Math.max(0, w - 2)) + right;
38
+ }
39
+
40
+ export function TopPanel({ cols, status, thinkingMs }) {
41
+ const innerW = Math.max(4, cols - 4);
42
+
43
+ if (cols < 50) {
44
+ return h(Box, { flexDirection: 'column', width: cols, backgroundColor: hex.cardBg },
45
+ h(Box, { height: 1 },
46
+ h(Text, { color: hex.border }, borderLine(cols, '\u250C', '\u2500', '\u2510'))
47
+ ),
48
+ h(Box, { height: 1, paddingLeft: 1, paddingRight: 1, backgroundColor: hex.cardBg },
49
+ h(Text, { bold: true, backgroundColor: hex.cardBg }, appGradient('CLARITY')),
50
+ h(Text, { color: hex.textMuted, backgroundColor: hex.cardBg }, ' '),
51
+ h(StatusChip, { status, thinkingMs }),
52
+ h(Text, { color: hex.textMuted, backgroundColor: hex.cardBg }, ' '.repeat(Math.max(0, innerW - 16)))
53
+ ),
54
+ h(Box, { height: 1 },
55
+ h(Text, { color: hex.border }, borderLine(cols, '\u2514', '\u2500', '\u2518'))
56
+ ),
57
+ );
58
+ }
59
+
60
+ const lines = cols < 60 ? BANNER_COMPACT : BANNER;
61
+ const panelH = lines.length + 2;
62
+
63
+ return h(Box, { flexDirection: 'column', width: cols, backgroundColor: hex.cardBg },
64
+ h(Box, { height: 1 },
65
+ h(Text, { color: hex.border }, borderLine(cols, '\u250C', '\u2500', '\u2510'))
66
+ ),
67
+ lines.map((line, i) =>
68
+ h(Box, { key: i, height: 1, backgroundColor: hex.cardBg },
69
+ h(Text, { color: hex.border, backgroundColor: hex.cardBg }, '\u2502'),
70
+ h(Text, { bold: true, backgroundColor: hex.cardBg }, appGradient.multiline(line)),
71
+ h(Text, { color: hex.border, backgroundColor: hex.cardBg },
72
+ ' '.repeat(Math.max(0, cols - line.length - 2)) + '\u2502')
73
+ )
74
+ ),
75
+ h(Box, { height: 1, backgroundColor: hex.cardBg },
76
+ h(Text, { color: hex.border, backgroundColor: hex.cardBg }, '\u2502'),
77
+ h(StatusChip, { status, thinkingMs }),
78
+ h(Text, { color: hex.textMuted, backgroundColor: hex.cardBg },
79
+ ' tab agents ctrl+p commands' + ' '.repeat(Math.max(0, innerW - 46)) + '\u2502')
80
+ ),
81
+ h(Box, { height: 1 },
82
+ h(Text, { color: hex.border }, borderLine(cols, '\u251C', '\u2500', '\u2524'))
83
+ ),
84
+ );
85
+ }
@@ -21,6 +21,9 @@ export const hex = {
21
21
  text: '#EEEEF0',
22
22
  textDim: '#9999AA',
23
23
  textMuted: '#555566',
24
+ border: '#2A2A3A',
25
+ thinkingBg: '#14141E',
26
+ thinkingText: '#666688',
24
27
  };
25
28
 
26
29
  export const color = {
@@ -1,41 +0,0 @@
1
- import React from 'react';
2
- import { Box, Text } from 'ink';
3
- import { appGradient } from '../config/theme.js';
4
- const { createElement: h } = React;
5
-
6
- const BANNER_FULL = [
7
- ' ██████╗██╗ █████╗ ██████╗ ██╗████████╗██╗ ██╗',
8
- '██╔════╝██║ ██╔══██╗██╔══██╗██║╚══██╔══╝╚██╗ ██╔╝',
9
- '██║ ███╗██║ ███████║██████╔╝██║ ██║ ╚████╔╝ ',
10
- '██║ ██║██║ ██╔══██║██╔══██╗██║ ██║ ╚██╔╝ ',
11
- '╚██████╔╝███████╗██║ ██║██║ ██║██║ ██║ ██║ ',
12
- ' ╚═════╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ',
13
- ];
14
-
15
- const BANNER_MED = [
16
- '██████╗ ██╗ █████╗ ██████╗ ██╗████████╗██╗ ██╗',
17
- '██╔════╝ ██║ ██╔══██╗██╔══██╗██║╚══██╔══╝╚██╗ ██╔╝',
18
- '███████╗ ██║ ███████║██████╔╝██║ ██║ ╚████╔╝ ',
19
- '╚════██║ ██║ ██╔══██║██╔══██╗██║ ██║ ╚██╔╝ ',
20
- '██████╔╝ ███████╗██║ ██║██║ ██║██║ ██║ ██║ ',
21
- '╚═════╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ',
22
- ];
23
-
24
- export function Banner({ cols }) {
25
- if (cols < 50) {
26
- return h(Box, { height: 2, width: '100%', alignItems: 'center', justifyContent: 'center' },
27
- h(Text, { bold: true }, appGradient('CLARITY'))
28
- );
29
- }
30
-
31
- const lines = cols < 60 ? BANNER_MED : BANNER_FULL;
32
- const bannerHeight = lines.length;
33
-
34
- return h(Box, { height: bannerHeight, width: '100%', alignItems: 'center', justifyContent: 'center', flexDirection: 'column' },
35
- lines.map((line, i) =>
36
- h(Box, { key: i, height: 1 },
37
- h(Text, { bold: true }, appGradient.multiline(line))
38
- )
39
- )
40
- );
41
- }
@@ -1,24 +0,0 @@
1
- import React from 'react';
2
- import { Box, Text } from 'ink';
3
- import { hex } from '../config/theme.js';
4
- const { createElement: h } = React;
5
-
6
- export function Footer({ cols }) {
7
- const showFull = cols >= 58;
8
- const keybindText = 'tab agents ctrl+p commands';
9
-
10
- return h(Box, { width: '100%', flexDirection: 'column', backgroundColor: hex.bg },
11
- h(Box, { height: 1, justifyContent: 'flex-end', paddingRight: 2, backgroundColor: hex.bg },
12
- h(Text, { color: hex.textMuted }, keybindText)
13
- ),
14
- h(Box, { height: 1, paddingLeft: 2, paddingRight: 2, backgroundColor: hex.bg },
15
- h(Text, { color: hex.textDim },
16
- '\u2580 ',
17
- h(Text, { color: hex.orange }, 'Tip'),
18
- h(Text, { color: hex.textDim }, ': ' + (showFull
19
- ? 'Use /help to view system commands and switch active models'
20
- : 'Use /help for commands'))
21
- )
22
- )
23
- );
24
- }
@@ -1,29 +0,0 @@
1
- import React from 'react';
2
- import { Box, Text } from 'ink';
3
- import TextInput from 'ink-text-input';
4
- import { hex } from '../config/theme.js';
5
- const { createElement: h } = React;
6
-
7
- export function InputDock({ width, provider, model, agentMode, status, input, onInputChange, onSubmit }) {
8
- const isLocked = status !== 'idle';
9
- const mShort = model.replace(/^[^/]+\//, '').slice(0, 18);
10
-
11
- return h(Box, { width, flexDirection: 'column', backgroundColor: hex.cardBg },
12
- h(Box, { height: 1, backgroundColor: hex.orange }),
13
- h(Box, { height: 1, paddingLeft: 2, paddingRight: 2, backgroundColor: hex.cardBg },
14
- h(TextInput, {
15
- value: input,
16
- onChange: onInputChange,
17
- onSubmit,
18
- placeholder: 'Ask anything...',
19
- focus: !isLocked,
20
- })
21
- ),
22
- h(Box, { height: 1, paddingLeft: 2, paddingRight: 2, backgroundColor: hex.cardBg },
23
- h(Text, { color: hex.textMuted },
24
- (provider || 'groq') + ' \u00B7 ' + mShort + (agentMode ? ' \u00B7 AGENT' : '')
25
- )
26
- ),
27
- h(Box, { height: 1, backgroundColor: hex.cardBg }),
28
- );
29
- }
@@ -1,45 +0,0 @@
1
- import React, { useState, useEffect } from 'react';
2
- import { Box, Text } from 'ink';
3
- import { hex } from '../config/theme.js';
4
- const { createElement: h } = React;
5
-
6
- export function StatusBar({ status, thinkingStart }) {
7
- const [elapsed, setElapsed] = useState(0);
8
-
9
- useEffect(() => {
10
- if (status === 'idle') { setElapsed(0); return; }
11
- const id = setInterval(() => {
12
- setElapsed(thinkingStart ? Date.now() - thinkingStart : 0);
13
- }, 100);
14
- return () => clearInterval(id);
15
- }, [status, thinkingStart]);
16
-
17
- const isThinking = status === 'thinking';
18
- const isStreaming = status === 'streaming';
19
-
20
- if (status === 'idle') {
21
- return h(Box, { height: 1, width: '100%', alignItems: 'center', justifyContent: 'center' },
22
- h(Box, { backgroundColor: hex.surfaceAlt, paddingX: 1 },
23
- h(Text, { color: hex.textMuted }, ' IDLE ')
24
- )
25
- );
26
- }
27
-
28
- if (isThinking) {
29
- return h(Box, { height: 1, width: '100%', alignItems: 'center', justifyContent: 'center' },
30
- h(Box, { backgroundColor: hex.surfaceAlt, paddingX: 1 },
31
- h(Text, { color: hex.orange }, ' \u25CF THINKING ' + elapsed + 'ms ')
32
- )
33
- );
34
- }
35
-
36
- if (isStreaming) {
37
- return h(Box, { height: 1, width: '100%', alignItems: 'center', justifyContent: 'center' },
38
- h(Box, { backgroundColor: hex.surfaceAlt, paddingX: 1 },
39
- h(Text, { color: hex.blue }, ' \u25CF STREAMING ' + elapsed + 'ms ')
40
- )
41
- );
42
- }
43
-
44
- return null;
45
- }
@@ -1,45 +0,0 @@
1
- import { useEffect, useRef } from 'react';
2
- import { useStdin } from 'ink';
3
-
4
- export function useMouse(handler) {
5
- const { stdin } = useStdin();
6
- const handlerRef = useRef(handler);
7
- handlerRef.current = handler;
8
-
9
- useEffect(() => {
10
- process.stdout.write('\x1b[?1000h\x1b[?1006h');
11
-
12
- function onData(chunk) {
13
- const str = typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8');
14
-
15
- const sgr = str.match(/\x1b\[<(\d+);(\d+);(\d+)([Mm])/);
16
- if (sgr) {
17
- const code = parseInt(sgr[1]);
18
- const col = parseInt(sgr[2]);
19
- const row = parseInt(sgr[3]);
20
- const press = sgr[4] === 'M';
21
- if (press && handlerRef.current) {
22
- handlerRef.current({ col, row, button: code & 3 });
23
- }
24
- return;
25
- }
26
-
27
- const legacy = str.match(/\x1b\[M(.{3})/);
28
- if (legacy) {
29
- const chars = legacy[1];
30
- const cb = chars.charCodeAt(0) - 32;
31
- const col = chars.charCodeAt(1) - 32;
32
- const row = chars.charCodeAt(2) - 32;
33
- if (handlerRef.current) {
34
- handlerRef.current({ col, row, button: cb & 3 });
35
- }
36
- }
37
- }
38
-
39
- stdin.on('data', onData);
40
- return () => {
41
- stdin.removeListener('data', onData);
42
- process.stdout.write('\x1b[?1000l\x1b[?1006l');
43
- };
44
- }, [stdin]);
45
- }