clarity-ai 7.1.0 → 7.2.1

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,25 @@
2
2
 
3
3
  ---
4
4
 
5
+ ## 7.2.1 (2026-06-06)
6
+
7
+ ### Critical hotfix: mouse bleed elimination, CLARITY branding, TrueColor textures
8
+ - **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.
9
+ - **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).
10
+ - **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.
11
+ - **Stderr isolation maintained**: `console.log/error/warn` continue routing to `clarity-debug.log` to prevent stray backend traces from corrupting the UI layer.
12
+
13
+ ## 7.2.0 (2026-06-06)
14
+
15
+ ### Sticky floating overlay engine — touch selection, phase-driven layout, virtualized log
16
+ - **XTerm SGR mouse tracking**: `\x1b[?1000h\x1b[?1006h` on boot, full cleanup on exit. Parses `\x1b[<code;col;rowM` sequences to map terminal grid clicks to popup items.
17
+ - **Phase-driven layout transition**: `initial` (centered logo + input, `justifyContent: center`) → `chat` (banner at top, messages flex-grow, dock hard-locked to bottom). Triggers instantly on first user/assistant message.
18
+ - **Solid sticky bottom dock**: hard-locked 4-row InputDock at `rows - 4`. Graphite `#1C1C1C` fill with orange `#FF9F43` accent bar. Never slides or bounces during streaming.
19
+ - **Virtualized viewport**: `StreamView` measures each message's line count via `wrap-ansi` against current width. Slices message array to `rows - bannerH - statusH - dockH - footerH`. Pushes old lines off render block when overflowed.
20
+ - **Slash-command floating popup**: Absolute-positioned overlay above input dock. Auto-opens when input starts with `/`. Orange `#FF9F43` full-width selection bar with black text. Arrow-key navigation + mouse click selection.
21
+ - **Stderr log routing**: All `console.log/error/warn` redirected to `clarity-debug.log` file to prevent stray async output from corrupting the UI layer.
22
+ - **Concurrency lock**: Input disabled during `THINKING`/`STREAMING` states; keyboard fully captured until idle.
23
+
5
24
  ## 7.1.0 (2026-06-06)
6
25
 
7
26
  ### Premium UI/UX overhaul — high-texture capsule design with live streaming
package/bin/clarity.js CHANGED
@@ -4,10 +4,12 @@ import { render } from 'ink';
4
4
  import { App } from '../src/app.js';
5
5
  import { hasKey } from '../src/config/keys.js';
6
6
  import { createInterface } from 'readline';
7
+ import { createWriteStream } from 'fs';
7
8
 
8
9
  process.stdin.resume();
9
10
  process.stdin.setEncoding('utf8');
10
11
 
12
+ const logFile = createWriteStream('clarity-debug.log', { flags: 'a' });
11
13
  const originalLog = console.log;
12
14
  const originalError = console.error;
13
15
  const originalWarn = console.warn;
@@ -28,15 +30,18 @@ async function main() {
28
30
  process.stdin.resume();
29
31
  }
30
32
 
31
- console.log = function sandboxedLog() {};
32
- console.error = function sandboxedError() {};
33
- console.warn = function sandboxedWarn() {};
33
+ console.log = (...args) => logFile.write('[LOG] ' + args.map(String).join(' ') + '\n');
34
+ console.error = (...args) => logFile.write('[ERR] ' + args.map(String).join(' ') + '\n');
35
+ console.warn = (...args) => logFile.write('[WARN] ' + args.map(String).join(' ') + '\n');
34
36
 
35
37
  let keepAlive;
36
38
 
37
- const config = { provider, model: process.env.CLARITY_MODEL || 'groq/llama-3.3-70b-versatile' };
39
+ const config = {
40
+ provider,
41
+ model: process.env.CLARITY_MODEL || 'groq/llama-3.3-70b-versatile',
42
+ };
38
43
 
39
- const { clear, waitUntilExit } = render(React.createElement(App, { config }), {
44
+ const { clear } = render(React.createElement(App, { config }), {
40
45
  fullscreen: true,
41
46
  patchConsole: false,
42
47
  exitOnCtrlC: false,
@@ -49,6 +54,8 @@ async function main() {
49
54
  console.log = originalLog;
50
55
  console.error = originalError;
51
56
  console.warn = originalWarn;
57
+ logFile.write('[CLEANUP] CLARITY exiting\n');
58
+ logFile.end();
52
59
  try { clear(); } catch {}
53
60
  process.stdout.write('\x1b[?25h\x1b[0m');
54
61
  process.exit(0);
@@ -56,7 +63,9 @@ async function main() {
56
63
 
57
64
  process.on('SIGINT', () => cleanup());
58
65
  process.on('SIGTERM', () => cleanup());
59
- process.on('exit', () => { process.stdout.write('\x1b[?25h\x1b[0m'); });
66
+ process.on('exit', () => {
67
+ process.stdout.write('\x1b[?25h\x1b[0m');
68
+ });
60
69
 
61
70
  await new Promise(() => {});
62
71
  }
@@ -65,7 +74,9 @@ main().catch(err => {
65
74
  console.log = originalLog;
66
75
  console.error = originalError;
67
76
  console.warn = originalWarn;
77
+ logFile.write('[FATAL] ' + (err?.message || String(err)) + '\n');
78
+ logFile.end();
68
79
  process.stdout.write('\x1b[?25h\x1b[0m');
69
- console.error('\n\x1b[31mFatal error:\x1b[0m', err.message);
80
+ console.error('\n\x1b[31mFatal error:\x1b[0m', err?.message || err);
70
81
  process.exit(1);
71
82
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clarity-ai",
3
- "version": "7.1.0",
3
+ "version": "7.2.1",
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
@@ -1,36 +1,47 @@
1
1
  import React, { useState, useCallback, useRef, useEffect } from 'react';
2
- import { Box, Text, useStdout } from 'ink';
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 { getLayout } from './config/layout.js';
6
5
  import { Banner } from './components/Banner.js';
7
6
  import { StatusBar } from './components/StatusBar.js';
8
7
  import { StreamView } from './components/StreamView.js';
9
- import { InputPanel } from './components/InputPanel.js';
8
+ import { InputDock } from './components/InputDock.js';
9
+ import { SlashPopup } from './components/SlashPopup.js';
10
10
  import { Footer } from './components/Footer.js';
11
11
  const { createElement: h } = React;
12
12
 
13
13
  let abortController = null;
14
14
 
15
- export function getAbortController() {
16
- return abortController;
15
+ export function getAbortController() { return abortController; }
16
+ export function setAbortController(ac) { abortController = ac; }
17
+ export function cancelStream() {
18
+ if (abortController) { try { abortController.abort(); } catch {} abortController = null; }
17
19
  }
18
20
 
19
- export function setAbortController(ac) {
20
- abortController = ac;
21
+ const DOCK_H = 4;
22
+ const FOOTER_H = 2;
23
+ const STATUS_H = 1;
24
+ const POPUP_W = 44;
25
+
26
+ const COMMAND_ITEMS = [
27
+ { name: '/keys' }, { name: '/model' }, { name: '/provider' },
28
+ { name: '/agent' }, { name: '/stop' }, { name: '/clear' },
29
+ { name: '/export' }, { name: '/help' }, { name: '/exit' },
30
+ ];
31
+
32
+ function deriveStatus(state, sc) {
33
+ if (!state.thinking) return 'idle';
34
+ return sc ? 'streaming' : 'thinking';
21
35
  }
22
36
 
23
- export function cancelStream() {
24
- if (abortController) {
25
- try { abortController.abort(); } catch {}
26
- abortController = null;
27
- }
37
+ function hasRealMessages(msgs) {
38
+ return msgs.some(m => m.role === 'user' || m.role === 'assistant');
28
39
  }
29
40
 
30
- function deriveStatus(state, streamContent) {
31
- if (!state.thinking) return 'idle';
32
- if (state.thinking && streamContent) return 'streaming';
33
- return 'thinking';
41
+ function getFiltered(input) {
42
+ const q = input.replace(/^\//, '').toLowerCase();
43
+ if (!q) return COMMAND_ITEMS;
44
+ return COMMAND_ITEMS.filter(c => c.name.includes(q));
34
45
  }
35
46
 
36
47
  export function App({ config }) {
@@ -39,7 +50,9 @@ export function App({ config }) {
39
50
  const [state, setState] = useState(() => createChatState());
40
51
  const [streamContent, setStreamContent] = useState('');
41
52
  const [thinkingStart, setThinkingStart] = useState(null);
42
- const defaultModel = (config.model || 'groq/llama-3.3-70b-versatile').replace(/^[^/]+\//, '');
53
+ const [input, setInput] = useState('');
54
+ const [popupIdx, setPopupIdx] = useState(0);
55
+ const defaultModel = (config.model || '').replace(/^[^/]+\//, '') || 'llama-3.3-70b-versatile';
43
56
  const [model, setModel] = useState(defaultModel);
44
57
  const [provider, setProvider] = useState(config.provider || 'groq');
45
58
 
@@ -50,29 +63,28 @@ export function App({ config }) {
50
63
  modelRef.current = model;
51
64
  providerRef.current = provider;
52
65
 
66
+ const rows = dims.rows;
67
+ const cols = dims.cols;
68
+ const bannerH = cols < 50 ? 2 : (cols < 60 ? 3 : 5);
69
+ const status = deriveStatus(state, streamContent);
70
+ const isChat = hasRealMessages(state.messages);
71
+ const cardW = Math.min(cols - 4, 56);
72
+ const showPopup = input.startsWith('/') && status === 'idle';
73
+ const availLines = Math.max(2, rows - bannerH - STATUS_H - DOCK_H - FOOTER_H - 2);
74
+
53
75
  useEffect(() => {
54
- function onResize() {
55
- setDims({ rows: process.stdout.rows || 30, cols: process.stdout.columns || 80 });
56
- }
76
+ function onResize() { setDims({ rows: process.stdout.rows || 30, cols: process.stdout.columns || 80 }); }
57
77
  process.stdout.on('resize', onResize);
58
78
  return () => process.stdout.removeListener('resize', onResize);
59
79
  }, []);
60
80
 
61
81
  useEffect(() => {
62
- if (state.thinking && !thinkingStart) {
63
- setThinkingStart(Date.now());
64
- } else if (!state.thinking) {
65
- setThinkingStart(null);
66
- }
82
+ if (state.thinking && !thinkingStart) setThinkingStart(Date.now());
83
+ else if (!state.thinking) setThinkingStart(null);
67
84
  }, [state.thinking]);
68
85
 
69
- const cols = dims.cols;
70
- const rows = dims.rows;
71
- const status = deriveStatus(state, streamContent);
72
- const cardWidth = Math.min(cols - 4, 56);
73
-
74
- const onSubmit = useCallback(async (input) => {
75
- if (input === '/exit') { process.exit(0); return; }
86
+ const onSubmit = useCallback(async (val) => {
87
+ if (val === '/exit') { process.exit(0); return; }
76
88
  cancelStream();
77
89
  const ac = new AbortController();
78
90
  setAbortController(ac);
@@ -80,42 +92,83 @@ export function App({ config }) {
80
92
  setState(s => ({ ...s, thinking: false, streamContent: '', agentStatus: '', toolExecutions: [], thoughtTimer: null }));
81
93
  setStreamContent('');
82
94
  });
83
- await handleSend(stateRef.current, setState, input, modelRef.current, providerRef.current, setStreamContent, ac.signal);
95
+ await handleSend(stateRef.current, setState, val, modelRef.current, providerRef.current, setStreamContent, ac.signal);
84
96
  }, []);
85
97
 
86
- const onCommand = useCallback(async (input) => {
87
- if (input.startsWith('/stop')) { cancelStream(); return; }
88
- if (input.startsWith('/exit')) { process.exit(0); return; }
89
- await handleCommand(input, stateRef.current, setState, setModel, setProvider, modelRef.current, providerRef.current);
98
+ const onCommand = useCallback(async (val) => {
99
+ if (val === '/stop') { cancelStream(); return; }
100
+ if (val === '/exit') { process.exit(0); return; }
101
+ await handleCommand(val, stateRef.current, setState, setModel, setProvider, modelRef.current, providerRef.current);
90
102
  }, []);
91
103
 
92
- function handleInputSubmit(value) {
93
- const trimmed = value.trim();
94
- if (!trimmed) return;
95
- if (trimmed.startsWith('/')) {
96
- onCommand(trimmed);
97
- } else {
98
- onSubmit(trimmed);
104
+ function handleSubmit(val) {
105
+ const t = val.trim();
106
+ if (!t) return;
107
+ setInput('');
108
+ setPopupIdx(0);
109
+ if (t.startsWith('/')) onCommand(t);
110
+ else onSubmit(t);
111
+ }
112
+
113
+ function handleInputChange(val) {
114
+ setInput(val);
115
+ if (val.startsWith('/')) setPopupIdx(0);
116
+ }
117
+
118
+ function handlePopupSelect(cmd) {
119
+ setInput('');
120
+ setPopupIdx(0);
121
+ if (cmd === '/exit') process.exit(0);
122
+ onCommand(cmd);
123
+ }
124
+
125
+ function closePopup() { setInput(''); setPopupIdx(0); }
126
+
127
+ useInput((ch, key) => {
128
+ if (!showPopup) return;
129
+ if (key.escape) { closePopup(); return; }
130
+ if (key.return) {
131
+ const filtered = getFiltered(input);
132
+ if (filtered[popupIdx]) handlePopupSelect(filtered[popupIdx].name);
133
+ return;
99
134
  }
135
+ if (key.upArrow) { setPopupIdx(i => Math.max(0, i - 1)); return; }
136
+ if (key.downArrow) {
137
+ const filtered = getFiltered(input);
138
+ setPopupIdx(i => Math.min(filtered.length - 1, i + 1));
139
+ }
140
+ });
141
+
142
+ const bottomArea = h(Box, { flexDirection: 'column', alignItems: 'center', width: '100%' },
143
+ showPopup
144
+ ? h(Box, { position: 'absolute', bottom: DOCK_H + FOOTER_H, alignItems: 'center', width: '100%' },
145
+ h(SlashPopup, { search: input, selectedIdx: popupIdx, width: POPUP_W })
146
+ )
147
+ : null,
148
+ h(InputDock, {
149
+ width: cardW, provider, model, agentMode: state.agentMode, status,
150
+ input, onInputChange: handleInputChange, onSubmit: handleSubmit,
151
+ }),
152
+ h(Footer, { cols })
153
+ );
154
+
155
+ if (!isChat) {
156
+ return h(Box, { width: '100%', height: rows, flexDirection: 'column', alignItems: 'center', justifyContent: 'center', backgroundColor: hex.bg },
157
+ h(Box, { flexDirection: 'column', alignItems: 'center', width: cardW },
158
+ h(Banner, { cols }),
159
+ h(StatusBar, { status, thinkingStart }),
160
+ h(Box, { height: 1 }),
161
+ bottomArea
162
+ )
163
+ );
100
164
  }
101
165
 
102
- return h(Box, { width: '100%', height: '100%', flexDirection: 'column', alignItems: 'center', backgroundColor: hex.bg },
103
- h(Box, { flexGrow: 1, minHeight: 1 }),
166
+ return h(Box, { width: '100%', height: rows, flexDirection: 'column', alignItems: 'center', backgroundColor: hex.bg },
104
167
  h(Banner, { cols }),
105
168
  h(StatusBar, { status, thinkingStart }),
106
- h(Box, { flexGrow: 2, width: cardWidth, minHeight: 2, flexDirection: 'column' },
107
- h(StreamView, { messages: state.messages, streamContent, status, width: cardWidth })
169
+ h(Box, { flexGrow: 1, width: cardW, flexDirection: 'column', overflow: 'hidden' },
170
+ h(StreamView, { messages: state.messages, streamContent, status, maxLines: availLines, width: cardW })
108
171
  ),
109
- h(Box, { height: 1 }),
110
- h(InputPanel, {
111
- width: cardWidth,
112
- provider,
113
- model,
114
- agentMode: state.agentMode,
115
- status,
116
- onSubmit: handleInputSubmit,
117
- }),
118
- h(Box, { flexGrow: 1, minHeight: 1 }),
119
- h(Footer, { cols })
172
+ bottomArea
120
173
  );
121
174
  }
@@ -3,22 +3,18 @@ import { Box, Text } from 'ink';
3
3
  import { appGradient } from '../config/theme.js';
4
4
  const { createElement: h } = React;
5
5
 
6
- const BANNER_FULL = [
7
- ' ██████╗██╗ █████╗ ██████╗ ██╗████████╗██╗ ██╗',
8
- '██╔════╝██║ ██╔══██╗██╔══██╗██║╚══██╔══╝╚██╗ ██╔╝',
9
- '██║ ███╗██║ ███████║██████╔╝██║ ██║ ╚████╔╝ ',
10
- '██║ ██║██║ ██╔══██║██╔══██╗██║ ██║ ╚██╔╝ ',
11
- '╚██████╔╝███████╗██║ ██║██║ ██║██║ ██║ ██║ ',
12
- ' ╚═════╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ',
6
+ const BANNER = [
7
+ '██████ ██ █████ ██████ ██ ████████ ██ ██',
8
+ '██ ██ ██ ██ ██ ██ ██ ██ ██ ██',
9
+ '██ ██ ███████ ██████ ██ ██ ████',
10
+ '██ ██ ██ ██ ██ ██ ██ ██ ██',
11
+ '██████ ███████ ██ ██ ██ ██ ██ ██ ██',
13
12
  ];
14
13
 
15
- const BANNER_MED = [
16
- '██████╗ ██╗ █████╗ ██████╗ ██╗████████╗██╗ ██╗',
17
- '██╔════╝ ██║ ██╔══██╗██╔══██╗██║╚══██╔══╝╚██╗ ██╔╝',
18
- '███████╗ ██║ ███████║██████╔╝██║ ██║ ╚████╔╝ ',
19
- '╚════██║ ██║ ██╔══██║██╔══██╗██║ ██║ ╚██╔╝ ',
20
- '██████╔╝ ███████╗██║ ██║██║ ██║██║ ██║ ██║ ',
21
- '╚═════╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ',
14
+ const BANNER_COMPACT = [
15
+ '██████ ██ █████ ██████ ██ ████████ ██ ██',
16
+ '██ ██ ██ ██ ██ ██ ██ ██ ██ ██',
17
+ '██████ ███████ ██ ██ ██ ██ ██ ██ ██',
22
18
  ];
23
19
 
24
20
  export function Banner({ cols }) {
@@ -28,10 +24,9 @@ export function Banner({ cols }) {
28
24
  );
29
25
  }
30
26
 
31
- const lines = cols < 60 ? BANNER_MED : BANNER_FULL;
32
- const bannerHeight = lines.length;
27
+ const lines = cols < 60 ? BANNER_COMPACT : BANNER;
33
28
 
34
- return h(Box, { height: bannerHeight, width: '100%', alignItems: 'center', justifyContent: 'center', flexDirection: 'column' },
29
+ return h(Box, { height: lines.length, width: '100%', alignItems: 'center', justifyContent: 'center', flexDirection: 'column' },
35
30
  lines.map((line, i) =>
36
31
  h(Box, { key: i, height: 1 },
37
32
  h(Text, { bold: true }, appGradient.multiline(line))
@@ -1,38 +1,28 @@
1
- import React, { useState } from 'react';
1
+ import React from 'react';
2
2
  import { Box, Text } from 'ink';
3
3
  import TextInput from 'ink-text-input';
4
4
  import { hex } from '../config/theme.js';
5
5
  const { createElement: h } = React;
6
6
 
7
- export function InputPanel({ width, provider, model, agentMode, status, onSubmit }) {
8
- const [input, setInput] = useState('');
7
+ export function InputDock({ width, provider, model, agentMode, status, input, onInputChange, onSubmit }) {
9
8
  const isLocked = status !== 'idle';
10
9
  const mShort = model.replace(/^[^/]+\//, '').slice(0, 18);
11
- const innerW = Math.max(4, width - 4);
12
-
13
- function handleSubmit(value) {
14
- const trimmed = value.trim();
15
- if (!trimmed) return;
16
- onSubmit(trimmed);
17
- setInput('');
18
- }
19
10
 
20
11
  return h(Box, { width, flexDirection: 'column', backgroundColor: hex.cardBg },
21
12
  h(Box, { height: 1, backgroundColor: hex.orange }),
22
13
  h(Box, { height: 1, paddingLeft: 2, paddingRight: 2, backgroundColor: hex.cardBg },
23
14
  h(TextInput, {
24
15
  value: input,
25
- onChange: setInput,
26
- onSubmit: handleSubmit,
16
+ onChange: onInputChange,
17
+ onSubmit,
27
18
  placeholder: 'Ask anything...',
28
19
  focus: !isLocked,
29
20
  })
30
21
  ),
31
22
  h(Box, { height: 1, paddingLeft: 2, paddingRight: 2, backgroundColor: hex.cardBg },
32
23
  h(Text, { color: hex.textMuted },
33
- provider + ' \u00B7 ' + mShort + (agentMode ? ' \u00B7 AGENT' : '') +
34
- ' '.repeat(Math.max(0, innerW - provider.length - mShort.length - (agentMode ? 10 : 4))) +
35
- (isLocked ? '' : ''))
24
+ (provider || 'groq') + ' \u00B7 ' + mShort + (agentMode ? ' \u00B7 AGENT' : '')
25
+ )
36
26
  ),
37
27
  h(Box, { height: 1, backgroundColor: hex.cardBg }),
38
28
  );
@@ -0,0 +1,57 @@
1
+ import React, { useMemo } from 'react';
2
+ import { Box, Text } from 'ink';
3
+ import { hex } from '../config/theme.js';
4
+ const { createElement: h } = React;
5
+
6
+ const COMMANDS = [
7
+ { name: '/keys', desc: 'Set API key' },
8
+ { name: '/model', desc: 'Switch model' },
9
+ { name: '/provider', desc: 'Switch provider' },
10
+ { name: '/agent', desc: 'Toggle agent mode' },
11
+ { name: '/stop', desc: 'Cancel running stream' },
12
+ { name: '/clear', desc: 'Clear conversation' },
13
+ { name: '/export', desc: 'Export conversation' },
14
+ { name: '/help', desc: 'Show all commands' },
15
+ { name: '/exit', desc: 'Exit CLARITY' },
16
+ ];
17
+
18
+ export function SlashPopup({ search, selectedIdx, width }) {
19
+ const filtered = useMemo(() => {
20
+ const q = search.replace(/^\//, '').toLowerCase();
21
+ if (!q) return COMMANDS;
22
+ return COMMANDS.filter(c =>
23
+ c.name.toLowerCase().includes(q) || c.desc.toLowerCase().includes(q)
24
+ );
25
+ }, [search]);
26
+
27
+ const innerW = Math.max(10, width - 4);
28
+ const label = (cmd, i) => {
29
+ const sel = i === selectedIdx;
30
+ const text = ' ' + cmd.name + ' ' + cmd.desc;
31
+ return text.length > innerW ? text.slice(0, innerW - 2) + '\u2026' : text;
32
+ };
33
+
34
+ return h(Box, { flexDirection: 'column', width, backgroundColor: hex.cardBg },
35
+ h(Box, { height: 1, backgroundColor: hex.surfaceAlt },
36
+ h(Text, { color: hex.textMuted, backgroundColor: hex.surfaceAlt },
37
+ ' ' + (search || '/') + ' '.repeat(Math.max(0, innerW - (search || '/').length)))
38
+ ),
39
+ filtered.map((cmd, i) =>
40
+ h(Box, {
41
+ key: cmd.name,
42
+ height: 1,
43
+ backgroundColor: i === selectedIdx ? hex.orange : hex.cardBg,
44
+ },
45
+ h(Text, {
46
+ color: i === selectedIdx ? '#000000' : hex.text,
47
+ bold: i === selectedIdx,
48
+ backgroundColor: i === selectedIdx ? hex.orange : hex.cardBg,
49
+ }, label(cmd, i))
50
+ )
51
+ ),
52
+ filtered.length === 0
53
+ ? h(Box, { height: 1, backgroundColor: hex.cardBg },
54
+ h(Text, { color: hex.textMuted, backgroundColor: hex.cardBg }, ' No matching commands'))
55
+ : null,
56
+ );
57
+ }
@@ -1,103 +1,118 @@
1
- import React, { useMemo, useRef, useEffect } 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
  import wrapAnsi from 'wrap-ansi';
5
- import stringWidth from 'string-width';
6
5
  const { createElement: h } = React;
7
6
 
8
- function wrapText(text, width) {
7
+ function lineCount(text, width) {
8
+ if (!text) return 1;
9
+ return wrapAnsi(String(text), Math.max(4, width), { trim: false, hard: true }).split('\n').length;
10
+ }
11
+
12
+ function measure(msg, width) {
13
+ const cw = Math.max(4, width);
14
+ if (msg.role === 'user') return 1 + lineCount(msg.content, cw);
15
+ 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
+ return 1;
19
+ }
20
+
21
+ function wrap(text, width) {
9
22
  if (!text) return [];
10
23
  return wrapAnsi(String(text), Math.max(4, width), { trim: false, hard: true }).split('\n');
11
24
  }
12
25
 
13
- function MessageBlock({ msg, width }) {
14
- const lines = useMemo(() => wrapText(msg.content, width), [msg.content, width]);
26
+ function RoleIcon({ role }) {
27
+ switch (role) {
28
+ case 'user': return h(Text, { color: hex.orange, bold: true }, '\u276F ');
29
+ case 'assistant': return h(Text, { color: hex.purple, bold: true }, '\u25C6 ');
30
+ case 'system': return h(Text, { color: hex.blue }, '\u25C9 ');
31
+ case 'error': return h(Text, { color: hex.red }, '\u2716 ');
32
+ case 'tool': return h(Text, { color: hex.green }, '\u25C9 ');
33
+ default: return null;
34
+ }
35
+ }
36
+
37
+ function MsgBlock({ msg, width }) {
38
+ const lines = useMemo(() => wrap(msg.content, width), [msg.content, width]);
39
+ const bg = msg.role === 'user' ? hex.surface : hex.cardBg;
15
40
 
16
- switch (msg.role) {
17
- case 'user':
18
- return h(Box, { flexDirection: 'column', marginBottom: 0 },
19
- h(Box, { height: 1 },
20
- h(Text, { color: hex.orange, bold: true }, ' \u276F '),
21
- h(Text, { color: hex.text }, lines[0] || '')
22
- ),
23
- lines.slice(1).map((l, i) =>
24
- h(Box, { key: i, height: 1 },
25
- h(Text, { color: hex.text }, ' ' + l)
26
- )
41
+ return h(Box, { flexDirection: 'column', backgroundColor: bg },
42
+ h(Box, { height: 1, backgroundColor: bg },
43
+ h(RoleIcon, { role: msg.role }),
44
+ h(Text, { color: hex.text, backgroundColor: bg }, lines[0] || '')
45
+ ),
46
+ lines.slice(1).map((l, i) =>
47
+ h(Box, { key: i, height: 1, backgroundColor: bg },
48
+ h(Text, { color: hex.text, backgroundColor: bg }, ' ' + l)
49
+ )
50
+ ),
51
+ msg.role === 'assistant' && msg.duration
52
+ ? h(Box, { height: 1, backgroundColor: bg },
53
+ h(Text, { color: hex.textMuted, backgroundColor: bg },
54
+ ' ' + (msg.duration < 1000 ? msg.duration + 'ms' : (msg.duration / 1000).toFixed(1) + 's'))
27
55
  )
28
- );
29
- case 'assistant':
30
- return h(Box, { flexDirection: 'column', marginBottom: 1 },
31
- h(Box, { height: 1 },
32
- h(Text, { color: hex.purple, bold: true }, ' \u25C6 '),
33
- h(Text, { color: hex.text }, lines[0] || '')
34
- ),
35
- lines.slice(1).map((l, i) =>
36
- h(Box, { key: i, height: 1 },
37
- h(Text, { color: hex.text }, ' ' + l)
38
- )
39
- ),
40
- msg.duration
41
- ? h(Box, { height: 1 },
42
- h(Text, { color: hex.textMuted }, ' ' + (msg.duration < 1000 ? msg.duration + 'ms' : (msg.duration / 1000).toFixed(1) + 's'))
43
- )
44
- : null
45
- );
46
- case 'system':
47
- return h(Box, { height: 1 },
48
- h(Text, { color: hex.blue }, ' \u25C9 ' + lines[0] || '')
49
- );
50
- case 'tool':
51
- return h(Box, { height: 1 },
52
- h(Text, { color: hex.green }, ' \u25C9 ' + (msg.toolName || 'tool') + (msg.duration ? ' ' + msg.duration + 'ms' : ''))
53
- );
54
- case 'error':
55
- return h(Box, { height: 1 },
56
- h(Text, { color: hex.red }, ' \u2716 ' + lines[0] || msg.content)
57
- );
58
- default:
59
- return null;
60
- }
56
+ : null
57
+ );
61
58
  }
62
59
 
63
60
  function StreamingBlock({ content, width }) {
64
- const lines = useMemo(() => wrapText(content, width), [content, width]);
65
- const visible = lines.slice(-100);
66
- const first = visible[0] || '';
67
-
68
- return h(Box, { flexDirection: 'column' },
69
- h(Box, { height: 1 },
70
- h(Text, { color: hex.blue, bold: true }, ' \u25CF '),
71
- h(Text, { color: hex.text }, first)
61
+ const lines = useMemo(() => wrap(content, width), [content, width]);
62
+ return h(Box, { flexDirection: 'column', backgroundColor: hex.cardBg },
63
+ h(Box, { height: 1, backgroundColor: hex.cardBg },
64
+ h(Text, { color: hex.blue, bold: true, backgroundColor: hex.cardBg }, '\u25CF '),
65
+ h(Text, { color: hex.text, backgroundColor: hex.cardBg }, lines[0] || '')
72
66
  ),
73
- visible.slice(1).map((l, i) =>
74
- h(Box, { key: i, height: 1 },
75
- h(Text, { color: hex.text }, ' ' + l)
67
+ lines.slice(1).map((l, i) =>
68
+ h(Box, { key: i, height: 1, backgroundColor: hex.cardBg },
69
+ h(Text, { color: hex.text, backgroundColor: hex.cardBg }, ' ' + l)
76
70
  )
77
71
  )
78
72
  );
79
73
  }
80
74
 
81
- export function StreamView({ messages, streamContent, status, width }) {
82
- const contentW = Math.max(4, width - 2);
75
+ export function StreamView({ messages, streamContent, status, maxLines, width }) {
76
+ const cw = Math.max(4, width - 2);
83
77
 
84
- const displayMessages = useMemo(() => {
78
+ const visible = useMemo(() => {
85
79
  if (messages.length === 0) {
86
80
  return [{ id: 'welcome', role: 'system', content: 'CLARITY AI ready \u00B7 /help for commands' }];
87
81
  }
88
- return messages;
89
- }, [messages]);
82
+
83
+ let avail = maxLines;
84
+ const result = [];
85
+ for (let i = messages.length - 1; i >= 0; i--) {
86
+ const needed = measure(messages[i], cw);
87
+ if (needed > avail) {
88
+ if (result.length === 0 && needed > avail) {
89
+ result.unshift(messages[i]);
90
+ }
91
+ break;
92
+ }
93
+ avail -= needed;
94
+ result.unshift(messages[i]);
95
+ }
96
+
97
+ if (streamContent && (status === 'streaming' || status === 'thinking')) {
98
+ const streamNeeded = 1 + lineCount(streamContent, cw);
99
+ while (streamNeeded > avail && result.length > 0) {
100
+ avail += measure(result[0], cw);
101
+ result.shift();
102
+ }
103
+ }
104
+
105
+ return result;
106
+ }, [messages, streamContent, status, maxLines, cw]);
90
107
 
91
108
  return h(Box, { flexDirection: 'column', width: '100%', paddingLeft: 1, paddingRight: 1 },
92
- displayMessages.map(m =>
93
- h(MessageBlock, { key: m.id, msg: m, width: contentW })
94
- ),
109
+ visible.map(m => h(MsgBlock, { key: m.id, msg: m, width: cw })),
95
110
  (status === 'streaming' || status === 'thinking') && streamContent
96
- ? h(StreamingBlock, { content: streamContent, width: contentW })
111
+ ? h(StreamingBlock, { content: streamContent, width: cw })
97
112
  : null,
98
113
  status === 'thinking' && !streamContent
99
- ? h(Box, { height: 1 },
100
- h(Text, { color: hex.textMuted }, ' \u25CF processing...')
114
+ ? h(Box, { height: 1, backgroundColor: hex.cardBg },
115
+ h(Text, { color: hex.textMuted, backgroundColor: hex.cardBg }, ' \u25CF processing...')
101
116
  )
102
117
  : null,
103
118
  );