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 +11 -0
- package/bin/clarity.js +20 -9
- package/package.json +1 -1
- package/src/app.js +123 -60
- package/src/components/{InputPanel.js → InputDock.js} +6 -16
- package/src/components/SlashPopup.js +55 -0
- package/src/components/StreamView.js +79 -65
- package/src/hooks/useMouse.js +45 -0
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 =
|
|
32
|
-
console.error =
|
|
33
|
-
console.warn =
|
|
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 = {
|
|
39
|
+
const config = {
|
|
40
|
+
provider,
|
|
41
|
+
model: process.env.CLARITY_MODEL || 'groq/llama-3.3-70b-versatile',
|
|
42
|
+
};
|
|
38
43
|
|
|
39
|
-
const { clear
|
|
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', () => {
|
|
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
|
-
|
|
69
|
-
|
|
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
package/src/app.js
CHANGED
|
@@ -1,36 +1,36 @@
|
|
|
1
1
|
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
|
2
|
-
import { Box,
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
+
const DOCK_H = 4;
|
|
23
|
+
const FOOTER_H = 2;
|
|
24
|
+
const STATUS_H = 1;
|
|
25
|
+
const POPUP_W = 44;
|
|
22
26
|
|
|
23
|
-
|
|
24
|
-
if (
|
|
25
|
-
|
|
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
|
|
31
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
70
|
-
|
|
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,
|
|
84
|
+
await handleSend(stateRef.current, setState, val, modelRef.current, providerRef.current, setStreamContent, ac.signal);
|
|
84
85
|
}, []);
|
|
85
86
|
|
|
86
|
-
const onCommand = useCallback(async (
|
|
87
|
-
if (
|
|
88
|
-
if (
|
|
89
|
-
await handleCommand(
|
|
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
|
|
93
|
-
const
|
|
94
|
-
if (!
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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:
|
|
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:
|
|
107
|
-
h(StreamView, { messages: state.messages, streamContent, status, width:
|
|
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
|
-
|
|
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
|
|
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
|
|
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:
|
|
26
|
-
onSubmit
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
14
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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(() =>
|
|
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 }, '
|
|
71
|
-
h(Text, { color: hex.text },
|
|
63
|
+
h(Text, { color: hex.blue, bold: true }, '\u25CF '),
|
|
64
|
+
h(Text, { color: hex.text }, lines[0] || '')
|
|
72
65
|
),
|
|
73
|
-
|
|
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
|
|
74
|
+
export function StreamView({ messages, streamContent, status, maxLines, width }) {
|
|
75
|
+
const cw = Math.max(4, width - 2);
|
|
83
76
|
|
|
84
|
-
const
|
|
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
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
+
}
|