clarity-ai 7.1.0 → 7.2.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,17 @@
2
2
 
3
3
  ---
4
4
 
5
+ ## 7.2.0 (2026-06-06)
6
+
7
+ ### Sticky floating overlay engine — touch selection, phase-driven layout, virtualized log
8
+ - **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.
9
+ - **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.
10
+ - **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.
11
+ - **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.
12
+ - **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.
13
+ - **Stderr log routing**: All `console.log/error/warn` redirected to `clarity-debug.log` file to prevent stray async output from corrupting the UI layer.
14
+ - **Concurrency lock**: Input disabled during `THINKING`/`STREAMING` states; keyboard fully captured until idle.
15
+
5
16
  ## 7.1.0 (2026-06-06)
6
17
 
7
18
  ### 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,14 +54,18 @@ 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
- process.stdout.write('\x1b[?25h\x1b[0m');
60
+ process.stdout.write('\x1b[?1000l\x1b[?1006l\x1b[?25h\x1b[0m');
54
61
  process.exit(0);
55
62
  }
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[?1000l\x1b[?1006l\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;
68
- process.stdout.write('\x1b[?25h\x1b[0m');
69
- console.error('\n\x1b[31mFatal error:\x1b[0m', err.message);
77
+ logFile.write('[FATAL] ' + (err?.message || String(err)) + '\n');
78
+ logFile.end();
79
+ process.stdout.write('\x1b[?1000l\x1b[?1006l\x1b[?25h\x1b[0m');
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.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
@@ -1,36 +1,36 @@
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
+ import { useMouse } from './hooks/useMouse.js';
11
12
  const { createElement: h } = React;
12
13
 
13
14
  let abortController = null;
14
15
 
15
- export function getAbortController() {
16
- return abortController;
16
+ export function getAbortController() { return abortController; }
17
+ export function setAbortController(ac) { abortController = ac; }
18
+ export function cancelStream() {
19
+ if (abortController) { try { abortController.abort(); } catch {} abortController = null; }
17
20
  }
18
21
 
19
- export function setAbortController(ac) {
20
- abortController = ac;
21
- }
22
+ const DOCK_H = 4;
23
+ const FOOTER_H = 2;
24
+ const STATUS_H = 1;
25
+ const POPUP_W = 44;
22
26
 
23
- export function cancelStream() {
24
- if (abortController) {
25
- try { abortController.abort(); } catch {}
26
- abortController = null;
27
- }
27
+ function deriveStatus(state, sc) {
28
+ if (!state.thinking) return 'idle';
29
+ return sc ? 'streaming' : 'thinking';
28
30
  }
29
31
 
30
- function deriveStatus(state, streamContent) {
31
- if (!state.thinking) return 'idle';
32
- if (state.thinking && streamContent) return 'streaming';
33
- return 'thinking';
32
+ function hasRealMessages(msgs) {
33
+ return msgs.some(m => m.role === 'user' || m.role === 'assistant');
34
34
  }
35
35
 
36
36
  export function App({ config }) {
@@ -39,7 +39,9 @@ export function App({ config }) {
39
39
  const [state, setState] = useState(() => createChatState());
40
40
  const [streamContent, setStreamContent] = useState('');
41
41
  const [thinkingStart, setThinkingStart] = useState(null);
42
- const defaultModel = (config.model || 'groq/llama-3.3-70b-versatile').replace(/^[^/]+\//, '');
42
+ const [input, setInput] = useState('');
43
+ const [popupIdx, setPopupIdx] = useState(0);
44
+ const defaultModel = (config.model || '').replace(/^[^/]+\//, '') || 'llama-3.3-70b-versatile';
43
45
  const [model, setModel] = useState(defaultModel);
44
46
  const [provider, setProvider] = useState(config.provider || 'groq');
45
47
 
@@ -50,29 +52,28 @@ export function App({ config }) {
50
52
  modelRef.current = model;
51
53
  providerRef.current = provider;
52
54
 
55
+ const rows = dims.rows;
56
+ const cols = dims.cols;
57
+ const bannerH = cols < 50 ? 2 : 6;
58
+ const status = deriveStatus(state, streamContent);
59
+ const isChat = hasRealMessages(state.messages);
60
+ const cardW = Math.min(cols - 4, 56);
61
+ const showPopup = input.startsWith('/') && status === 'idle';
62
+ const availLines = Math.max(2, rows - bannerH - STATUS_H - DOCK_H - FOOTER_H - 2);
63
+
53
64
  useEffect(() => {
54
- function onResize() {
55
- setDims({ rows: process.stdout.rows || 30, cols: process.stdout.columns || 80 });
56
- }
65
+ function onResize() { setDims({ r: process.stdout.rows || 30, c: process.stdout.columns || 80 }); }
57
66
  process.stdout.on('resize', onResize);
58
67
  return () => process.stdout.removeListener('resize', onResize);
59
68
  }, []);
60
69
 
61
70
  useEffect(() => {
62
- if (state.thinking && !thinkingStart) {
63
- setThinkingStart(Date.now());
64
- } else if (!state.thinking) {
65
- setThinkingStart(null);
66
- }
71
+ if (state.thinking && !thinkingStart) setThinkingStart(Date.now());
72
+ else if (!state.thinking) setThinkingStart(null);
67
73
  }, [state.thinking]);
68
74
 
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; }
75
+ const onSubmit = useCallback(async (val) => {
76
+ if (val === '/exit') { process.exit(0); return; }
76
77
  cancelStream();
77
78
  const ac = new AbortController();
78
79
  setAbortController(ac);
@@ -80,42 +81,104 @@ export function App({ config }) {
80
81
  setState(s => ({ ...s, thinking: false, streamContent: '', agentStatus: '', toolExecutions: [], thoughtTimer: null }));
81
82
  setStreamContent('');
82
83
  });
83
- await handleSend(stateRef.current, setState, input, modelRef.current, providerRef.current, setStreamContent, ac.signal);
84
+ await handleSend(stateRef.current, setState, val, modelRef.current, providerRef.current, setStreamContent, ac.signal);
84
85
  }, []);
85
86
 
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);
87
+ const onCommand = useCallback(async (val) => {
88
+ if (val.startsWith('/stop')) { cancelStream(); return; }
89
+ if (val.startsWith('/exit')) { process.exit(0); return; }
90
+ await handleCommand(val, stateRef.current, setState, setModel, setProvider, modelRef.current, providerRef.current);
90
91
  }, []);
91
92
 
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);
93
+ function handleSubmit(val) {
94
+ const t = val.trim();
95
+ if (!t) return;
96
+ setInput('');
97
+ setPopupIdx(0);
98
+ if (t.startsWith('/')) onCommand(t);
99
+ else onSubmit(t);
100
+ }
101
+
102
+ function handleInputChange(val) {
103
+ setInput(val);
104
+ if (val.startsWith('/')) setPopupIdx(0);
105
+ }
106
+
107
+ function handlePopupSelect(cmd) {
108
+ setInput('');
109
+ setPopupIdx(0);
110
+ if (cmd === '/exit') process.exit(0);
111
+ if (cmd === '/stop') { cancelStream(); return; }
112
+ onCommand(cmd);
113
+ }
114
+
115
+ function closePopup() { setInput(''); setPopupIdx(0); }
116
+
117
+ 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));
133
+ }
134
+ });
135
+
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
+ }
99
147
  }
148
+ }, [showPopup, cols, rows]);
149
+
150
+ useMouse(handleClick);
151
+
152
+ const bottomArea = h(Box, { flexDirection: 'column', alignItems: 'center', width: '100%' },
153
+ 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 })
156
+ )
157
+ : null,
158
+ h(InputDock, {
159
+ width: cardW, provider, model, agentMode: state.agentMode, status,
160
+ input, onInputChange: handleInputChange, onSubmit: handleSubmit,
161
+ }),
162
+ h(Footer, { cols })
163
+ );
164
+
165
+ if (!isChat) {
166
+ 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
172
+ )
173
+ );
100
174
  }
101
175
 
102
- return h(Box, { width: '100%', height: '100%', flexDirection: 'column', alignItems: 'center', backgroundColor: hex.bg },
103
- h(Box, { flexGrow: 1, minHeight: 1 }),
176
+ return h(Box, { width: '100%', height: rows, flexDirection: 'column', alignItems: 'center', backgroundColor: hex.bg },
104
177
  h(Banner, { cols }),
105
178
  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 })
179
+ h(Box, { flexGrow: 1, width: cardW, flexDirection: 'column', overflow: 'hidden' },
180
+ h(StreamView, { messages: state.messages, streamContent, status, maxLines: availLines, width: cardW })
108
181
  ),
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 })
182
+ bottomArea
120
183
  );
121
184
  }
@@ -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,55 @@
1
+ import React, { useState, 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, onHover, 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 itemLabel = (cmd, i) => {
29
+ const sel = i === selectedIdx;
30
+ const label = ' ' + cmd.name + ' ' + cmd.desc;
31
+ return label.length > innerW ? label.slice(0, innerW - 2) + '\u2026' : label;
32
+ };
33
+
34
+ return h(Box, { flexDirection: 'column', width, backgroundColor: hex.surface },
35
+ h(Box, { height: 1, backgroundColor: hex.surfaceAlt },
36
+ h(Text, { color: hex.textMuted }, ' ' + (search || '/') + ' '.repeat(Math.max(0, innerW - (search || '/').length)))
37
+ ),
38
+ filtered.map((cmd, i) =>
39
+ h(Box, {
40
+ key: cmd.name,
41
+ height: 1,
42
+ backgroundColor: i === selectedIdx ? hex.orange : 'transparent',
43
+ },
44
+ h(Text, {
45
+ color: i === selectedIdx ? '#000000' : hex.text,
46
+ bold: i === selectedIdx,
47
+ }, itemLabel(cmd, i))
48
+ )
49
+ ),
50
+ filtered.length === 0
51
+ ? h(Box, { height: 1 },
52
+ h(Text, { color: hex.textMuted }, ' No matching commands'))
53
+ : null,
54
+ );
55
+ }
@@ -1,76 +1,69 @@
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 MsgRole({ 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]);
15
39
 
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
- )
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] || '')
44
+ ),
45
+ lines.slice(1).map((l, i) =>
46
+ h(Box, { key: i, height: 1 },
47
+ h(Text, { color: hex.text }, ' ' + l)
48
+ )
49
+ ),
50
+ msg.role === 'assistant' && msg.duration
51
+ ? h(Box, { height: 1 },
52
+ h(Text, { color: hex.textMuted },
53
+ ' ' + (msg.duration < 1000 ? msg.duration + 'ms' : (msg.duration / 1000).toFixed(1) + 's'))
27
54
  )
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
- }
55
+ : null
56
+ );
61
57
  }
62
58
 
63
59
  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
-
60
+ const lines = useMemo(() => wrap(content, width), [content, width]);
68
61
  return h(Box, { flexDirection: 'column' },
69
62
  h(Box, { height: 1 },
70
- h(Text, { color: hex.blue, bold: true }, ' \u25CF '),
71
- h(Text, { color: hex.text }, first)
63
+ h(Text, { color: hex.blue, bold: true }, '\u25CF '),
64
+ h(Text, { color: hex.text }, lines[0] || '')
72
65
  ),
73
- visible.slice(1).map((l, i) =>
66
+ lines.slice(1).map((l, i) =>
74
67
  h(Box, { key: i, height: 1 },
75
68
  h(Text, { color: hex.text }, ' ' + l)
76
69
  )
@@ -78,22 +71,43 @@ function StreamingBlock({ content, width }) {
78
71
  );
79
72
  }
80
73
 
81
- export function StreamView({ messages, streamContent, status, width }) {
82
- const contentW = Math.max(4, width - 2);
74
+ export function StreamView({ messages, streamContent, status, maxLines, width }) {
75
+ const cw = Math.max(4, width - 2);
83
76
 
84
- const displayMessages = useMemo(() => {
77
+ const visible = useMemo(() => {
85
78
  if (messages.length === 0) {
86
79
  return [{ id: 'welcome', role: 'system', content: 'CLARITY AI ready \u00B7 /help for commands' }];
87
80
  }
88
- return messages;
89
- }, [messages]);
81
+
82
+ let avail = maxLines;
83
+ const result = [];
84
+ for (let i = messages.length - 1; i >= 0; i--) {
85
+ const needed = measure(messages[i], cw);
86
+ if (needed > avail) {
87
+ if (result.length === 0 && needed > avail) {
88
+ result.unshift(messages[i]);
89
+ }
90
+ break;
91
+ }
92
+ avail -= needed;
93
+ result.unshift(messages[i]);
94
+ }
95
+
96
+ if (streamContent && (status === 'streaming' || status === 'thinking')) {
97
+ const streamNeeded = 1 + lineCount(streamContent, cw);
98
+ while (streamNeeded > avail && result.length > 0) {
99
+ avail += measure(result[0], cw);
100
+ result.shift();
101
+ }
102
+ }
103
+
104
+ return result;
105
+ }, [messages, streamContent, status, maxLines, cw]);
90
106
 
91
107
  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
- ),
108
+ visible.map(m => h(MsgBlock, { key: m.id, msg: m, width: cw })),
95
109
  (status === 'streaming' || status === 'thinking') && streamContent
96
- ? h(StreamingBlock, { content: streamContent, width: contentW })
110
+ ? h(StreamingBlock, { content: streamContent, width: cw })
97
111
  : null,
98
112
  status === 'thinking' && !streamContent
99
113
  ? h(Box, { height: 1 },
@@ -0,0 +1,45 @@
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
+ }