clarity-ai 6.2.3 → 6.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/bin/clarity.js CHANGED
@@ -1,20 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  import React from 'react';
3
3
  import { render } from 'ink';
4
- import { App } from '../src/app.js';
4
+ import { App } from '../src/components/AppRoot.js';
5
5
  import { hasKey } from '../src/config/keys.js';
6
6
  import { createInterface } from 'readline';
7
7
 
8
- // Keep stdin flowing so Ink's useInput hooks can register.
9
- // This must happen before any async I/O that might pause stdin.
10
8
  process.stdin.resume();
11
9
  process.stdin.setEncoding('utf8');
12
10
 
13
- // Do NOT clear the screen here. Ink's { fullscreen: true } switches
14
- // to the alternate screen buffer on mount, which implicitly clears
15
- // the visible area. Any manual \x1b[2J before render() would clear
16
- // the main buffer — destructive, shows as a flash on Termux.
17
-
18
11
  async function main() {
19
12
  const provider = process.env.CLARITY_PROVIDER || 'groq';
20
13
 
@@ -28,37 +21,27 @@ async function main() {
28
21
  });
29
22
  const { setKey } = await import('../src/config/keys.js');
30
23
  setKey(provider, key);
31
- // readline.close() can leave stdin paused — restore it for Ink
32
24
  process.stdin.resume();
33
25
  }
34
26
 
35
27
  const config = { provider, model: process.env.CLARITY_MODEL || 'groq/llama-3.3-70b-versatile' };
36
28
 
37
- // Mount Ink. fullscreen:true enters the alternate screen buffer.
38
- // We DO NOT await waitUntilExit; on some Termux/Node combos it
39
- // resolves before the first frame renders. Instead we keep the
40
- // process alive with a never-resolving promise and exit only via
41
- // signal handlers or process.exit from inside the app.
42
29
  const { clear } = render(React.createElement(App, { config }), {
43
30
  fullscreen: true,
44
31
  patchConsole: false,
45
32
  });
46
33
 
47
- // Keep the event loop alive — Ink's reconciler settles
48
- // synchronously when no async state updates are queued.
49
34
  setInterval(() => {}, 2 ** 31 - 1);
50
35
 
51
- // Signal handlers are the ONLY way to exit.
52
36
  function cleanup() {
53
- try { clear(); } catch {} // Ink unmount — exit alt buffer, restore TTY
54
- process.stdout.write('\x1b[?25h\x1b[0m'); // show cursor, reset attrs
37
+ try { clear(); } catch {}
38
+ process.stdout.write('\x1b[?25h\x1b[0m');
55
39
  process.exit(0);
56
40
  }
57
41
 
58
42
  process.on('SIGINT', () => cleanup());
59
43
  process.on('SIGTERM', () => cleanup());
60
44
 
61
- // Never-resolving promise — we stay alive until a signal fires cleanup.
62
45
  await new Promise(() => {});
63
46
  }
64
47
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "clarity-ai",
3
- "version": "6.2.3",
4
- "description": "Premium OpenCode-style terminal AI agent — 24-bit TrueColor theme, 8s timeout recovery, virtual scroll, inline tool trees, collapsible thought cards",
3
+ "version": "6.3.0",
4
+ "description": "Premium terminal AI agent — fixed-height viewport, box-drawing UI, TrueColor theme, streaming with abort",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "clarity": "bin/clarity.js"
@@ -16,14 +16,7 @@
16
16
  "ink": "^5",
17
17
  "react": "^18",
18
18
  "ink-spinner": "^5",
19
- "marked": "^12",
20
- "cli-highlight": "^2",
21
19
  "chalk": "^5",
22
- "ansi-escapes": "^7",
23
- "cli-cursor": "^5",
24
- "wrap-ansi": "^9",
25
- "strip-ansi": "^7",
26
- "string-width": "^7",
27
- "picocolors": "^1"
20
+ "wrap-ansi": "^9"
28
21
  }
29
22
  }
package/src/chat.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import { callAI } from './providers/index.js';
2
2
  import { setKey } from './config/keys.js';
3
3
  import { TOOLS, executeTool } from './tools.js';
4
+ import { cancelStream } from './components/AppRoot.js';
5
+
4
6
  const sleep = ms => new Promise(r => setTimeout(r, ms));
5
7
 
6
8
  export function createChatState() {
@@ -12,8 +14,6 @@ export function createChatState() {
12
14
  }],
13
15
  thinking: false,
14
16
  streamContent: '',
15
- awaitingKey: false,
16
- blockedProvider: null,
17
17
  agentMode: true,
18
18
  agentStatus: '',
19
19
  toolExecutions: [],
@@ -26,8 +26,8 @@ let execId = 0;
26
26
  function nextId() { return 'm' + (++msgId); }
27
27
  function nextExecId() { return 'x' + (++execId); }
28
28
 
29
- export async function handleSend(state, setState, input, model, provider, onStreamContent) {
30
- if (!input.trim() || state.awaitingKey) return;
29
+ export async function handleSend(state, setState, input, model, provider, onStreamContent, signal) {
30
+ if (!input.trim()) return;
31
31
 
32
32
  const userMsg = { id: nextId(), role: 'user', content: input };
33
33
  setState(s => ({
@@ -57,8 +57,9 @@ export async function handleSend(state, setState, input, model, provider, onStre
57
57
  return base;
58
58
  });
59
59
 
60
- await processStream(provider, model, history, state.agentMode, setState, onStreamContent);
60
+ await processStream(provider, model, history, state.agentMode, setState, onStreamContent, 0, signal);
61
61
  } catch (err) {
62
+ if (err.name === 'AbortError') return;
62
63
  const thoughtTime = state.thoughtTimer ? Date.now() - state.thoughtTimer : 0;
63
64
  setState(s => ({
64
65
  ...s, thinking: false, streamContent: '', agentStatus: '', toolExecutions: [], thoughtTimer: null,
@@ -73,18 +74,20 @@ export async function handleSend(state, setState, input, model, provider, onStre
73
74
  }
74
75
  }
75
76
 
76
- async function processStream(provider, model, history, agentMode, setState, onStreamContent, depth = 0) {
77
+ async function processStream(provider, model, history, agentMode, setState, onStreamContent, depth = 0, signal) {
77
78
  if (depth > 8) {
78
79
  setState(s => ({ ...s, thinking: false, streamContent: '', agentStatus: '', toolExecutions: [], thoughtTimer: null }));
79
80
  onStreamContent('');
80
81
  return;
81
82
  }
83
+ if (signal?.aborted) return;
82
84
 
83
85
  let stream;
84
86
  try {
85
- stream = callAI(provider, model, history, { tools: agentMode ? TOOLS : undefined });
87
+ stream = callAI(provider, model, history, { tools: agentMode ? TOOLS : undefined, signal });
86
88
  } catch (err) {
87
- if (err.type === 'rate_limit') { await sleep(2000); return processStream(provider, model, history, agentMode, setState, onStreamContent, depth); }
89
+ if (err.type === 'rate_limit') { await sleep(2000); return processStream(provider, model, history, agentMode, setState, onStreamContent, depth, signal); }
90
+ if (err.name === 'AbortError') return;
88
91
  throw err;
89
92
  }
90
93
 
@@ -95,13 +98,13 @@ async function processStream(provider, model, history, agentMode, setState, onSt
95
98
 
96
99
  try {
97
100
  for await (const event of stream) {
101
+ if (signal?.aborted) return;
98
102
  if (event.type === 'token') {
99
103
  buffer += event.content;
100
104
  onStreamContent(buffer);
101
105
  setState(s => ({ ...s, agentStatus: 'Writing response...' }));
102
106
  } else if (event.type === 'tool_calls') {
103
107
  if (!timedOut) toolCallsData = event.calls;
104
- } else if (event.type === 'done') {
105
108
  } else if (event.type === 'timeout') {
106
109
  timedOut = true;
107
110
  setState(s => ({
@@ -119,11 +122,13 @@ async function processStream(provider, model, history, agentMode, setState, onSt
119
122
  }
120
123
  }
121
124
  } catch (err) {
125
+ if (err.name === 'AbortError') return;
122
126
  if (err.type === 'rate_limit') {
123
127
  await sleep(2000);
124
128
  setState(s => ({ ...s, agentStatus: 'Retrying after rate limit...' }));
125
- return processStream(provider, model, history, agentMode, setState, onStreamContent, depth);
129
+ return processStream(provider, model, history, agentMode, setState, onStreamContent, depth, signal);
126
130
  }
131
+ if (signal?.aborted) return;
127
132
  throw err;
128
133
  }
129
134
 
@@ -142,7 +147,7 @@ async function processStream(provider, model, history, agentMode, setState, onSt
142
147
  onStreamContent('');
143
148
  }
144
149
 
145
- if (timedOut) {
150
+ if (timedOut || signal?.aborted) {
146
151
  setState(s => ({ ...s, thinking: false, toolExecutions: [], thoughtTimer: null }));
147
152
  onStreamContent('');
148
153
  return;
@@ -168,6 +173,7 @@ async function processStream(provider, model, history, agentMode, setState, onSt
168
173
 
169
174
  const toolResults = [];
170
175
  for (let i = 0; i < toolCallsData.length; i++) {
176
+ if (signal?.aborted) return;
171
177
  const tc = toolCallsData[i];
172
178
  const { name, arguments: argsStr } = tc.function;
173
179
  let args;
@@ -203,6 +209,8 @@ async function processStream(provider, model, history, agentMode, setState, onSt
203
209
  }));
204
210
  }
205
211
 
212
+ if (signal?.aborted) return;
213
+
206
214
  setState(s => ({
207
215
  ...s,
208
216
  agentStatus: 'Processing results...',
@@ -230,12 +238,15 @@ async function processStream(provider, model, history, agentMode, setState, onSt
230
238
  ...toolResults.map(tr => ({
231
239
  id: nextId(), role: 'tool', content: tr.content,
232
240
  tool_call_id: tr.tool_call_id, toolName: tr.name,
241
+ completed: true,
233
242
  })),
234
243
  ],
235
244
  toolExecutions: [],
236
245
  agentStatus: '',
237
246
  }));
238
247
 
248
+ if (signal?.aborted) return;
249
+
239
250
  const newHistory = [
240
251
  ...history,
241
252
  { role: 'assistant', content: buffer || null, tool_calls: toolCallsData.map(tc => ({
@@ -245,7 +256,7 @@ async function processStream(provider, model, history, agentMode, setState, onSt
245
256
  ...toolResults,
246
257
  ];
247
258
 
248
- await processStream(provider, model, newHistory, agentMode, setState, onStreamContent, depth + 1);
259
+ await processStream(provider, model, newHistory, agentMode, setState, onStreamContent, depth + 1, signal);
249
260
  } else {
250
261
  setState(s => ({
251
262
  ...s,
@@ -267,7 +278,7 @@ export async function handleCommand(input, state, setState, modelSetter, provide
267
278
  if (args.length >= 2) {
268
279
  setKey(args[0], args[1]);
269
280
  setState(s => ({
270
- ...s, awaitingKey: false, blockedProvider: null,
281
+ ...s,
271
282
  messages: [...s.messages, { id: nextId(), role: 'system', content: 'Key saved for ' + args[0] }],
272
283
  }));
273
284
  } else {
@@ -303,6 +314,7 @@ export async function handleCommand(input, state, setState, modelSetter, provide
303
314
  '/model Switch model',
304
315
  '/provider Switch provider',
305
316
  '/agent Toggle agent mode',
317
+ '/stop Cancel streaming',
306
318
  '/clear Clear conversation',
307
319
  '/export Export conversation',
308
320
  '/help Show this help',
@@ -1,12 +1,25 @@
1
- import React, { useState, useCallback, useRef } from 'react';
1
+ import React, { useState, useCallback, useRef, useEffect } from 'react';
2
2
  import { Box } from 'ink';
3
- import { MessageList } from './components/MessageList.js';
4
- import { Composer } from './components/Composer.js';
5
- import { CommandPicker } from './components/CommandPicker.js';
6
- import { ModelPicker } from './components/ModelPicker.js';
7
- import { createChatState, handleSend, handleCommand } from './chat.js';
8
- import { theme } from './config/theme.js';
9
- const { createElement: h } = React;
3
+ import { createChatState, handleSend, handleCommand } from '../chat.js';
4
+ import { hex } from '../config/theme.js';
5
+ import { Layout } from './Layout.js';
6
+
7
+ let abortController = null;
8
+
9
+ export function getAbortController() {
10
+ return abortController;
11
+ }
12
+
13
+ export function setAbortController(ac) {
14
+ abortController = ac;
15
+ }
16
+
17
+ export function cancelStream() {
18
+ if (abortController) {
19
+ try { abortController.abort(); } catch {}
20
+ abortController = null;
21
+ }
22
+ }
10
23
 
11
24
  export function App({ config }) {
12
25
  const [state, setState] = useState(() => createChatState());
@@ -20,18 +33,29 @@ export function App({ config }) {
20
33
  const stateRef = useRef(state);
21
34
  const modelRef = useRef(model);
22
35
  const providerRef = useRef(provider);
36
+ const streamRef = useRef(streamContent);
23
37
  stateRef.current = state;
24
38
  modelRef.current = model;
25
39
  providerRef.current = provider;
40
+ streamRef.current = streamContent;
26
41
 
27
42
  const onSubmit = useCallback(async (input) => {
43
+ if (input === '/exit') { process.exit(0); return; }
28
44
  if (input.startsWith('/')) {
29
45
  if (input === '/model' || input === '/models') { setShowModels(true); return; }
30
46
  if (input === '/help') { setShowCommands(true); return; }
47
+ if (input === '/stop') { cancelStream(); return; }
31
48
  await handleCommand(input, stateRef.current, setState, setModel, setProvider, modelRef.current, providerRef.current);
32
49
  return;
33
50
  }
34
- await handleSend(stateRef.current, setState, input, modelRef.current, providerRef.current, setStreamContent);
51
+ cancelStream();
52
+ const ac = new AbortController();
53
+ setAbortController(ac);
54
+ ac.signal.addEventListener('abort', () => {
55
+ setState(s => ({ ...s, thinking: false, streamContent: '', agentStatus: '', toolExecutions: [], thoughtTimer: null }));
56
+ setStreamContent('');
57
+ });
58
+ await handleSend(stateRef.current, setState, input, modelRef.current, providerRef.current, setStreamContent, ac.signal);
35
59
  }, []);
36
60
 
37
61
  function handleCommandSelect(cmdName) {
@@ -49,38 +73,18 @@ export function App({ config }) {
49
73
  }));
50
74
  }
51
75
 
52
- return h(Box, { flexDirection: 'column', backgroundColor: theme.bg, minHeight: '100%' },
53
- h(Box, { flexGrow: 1, flexDirection: 'column', minHeight: 0 },
54
- h(MessageList, {
55
- messages: state.messages,
56
- thinking: state.thinking,
57
- streamContent,
58
- agentStatus: state.agentStatus,
59
- toolExecutions: state.toolExecutions,
60
- })
61
- ),
62
- showCommands || showModels
63
- ? h(Box, { flexDirection: 'column', marginBottom: 1, backgroundColor: theme.surfaceAlt, borderStyle: 'round', borderColor: theme.borderLight },
64
- showCommands
65
- ? h(CommandPicker, {
66
- query: '',
67
- onSelect: handleCommandSelect,
68
- onClose: () => setShowCommands(false),
69
- })
70
- : null,
71
- showModels
72
- ? h(ModelPicker, {
73
- onSelect: handleModelSelect,
74
- onClose: () => setShowModels(false),
75
- })
76
- : null
77
- )
78
- : null,
79
- h(Composer, {
80
- provider,
76
+ return Box({ flexDirection: 'column', backgroundColor: hex.bg },
77
+ Layout({
78
+ state,
79
+ streamContent,
81
80
  model,
82
- agentMode: state.agentMode,
83
- thinking: state.thinking,
81
+ provider,
82
+ showCommands,
83
+ showModels,
84
+ onCommandSelect: handleCommandSelect,
85
+ onModelSelect: handleModelSelect,
86
+ onCloseCommands: () => setShowCommands(false),
87
+ onCloseModels: () => setShowModels(false),
84
88
  onSlash: () => setShowCommands(true),
85
89
  onSubmit,
86
90
  })
@@ -1,6 +1,7 @@
1
1
  import React, { useMemo } from 'react';
2
2
  import { Box, Text } from 'ink';
3
- import { theme } from '../config/theme.js';
3
+ import { hex, usym } from '../config/theme.js';
4
+ import { getLayout } from '../config/layout.js';
4
5
  const { createElement: h } = React;
5
6
 
6
7
  const LANG_COLORS = {
@@ -17,23 +18,30 @@ export function CodeBlock({ code, language }) {
17
18
  const lines = useMemo(() => String(code).split('\n'), [code]);
18
19
  const langColor = LANG_COLORS[lang] || '#555';
19
20
  const lnW = String(lines.length).length;
21
+ const { cols } = getLayout();
22
+ const maxLines = 20;
23
+ const visible = lines.slice(0, maxLines);
20
24
 
21
- return h(Box, { flexDirection: 'column', marginY: 1, marginLeft: 0 },
22
- h(Box, { flexDirection: 'row', backgroundColor: theme.codeBg },
23
- h(Text, { color: langColor, bold: true, backgroundColor: '#1C1C1C' }, ' ' + lang + ' '),
24
- h(Text, { color: theme.textMuted, backgroundColor: '#1C1C1C' }, String(lines.length) + ' lines '),
25
+ return h(Box, { flexDirection: 'column', backgroundColor: hex.codeBg },
26
+ h(Box, { flexDirection: 'row', backgroundColor: hex.codeBg },
27
+ h(Text, { color: langColor, bold: true, backgroundColor: hex.codeBg }, ' ' + lang + ' '),
28
+ h(Text, { color: hex.textMuted, backgroundColor: hex.codeBg }, String(lines.length) + ' lines '),
25
29
  ),
26
- h(Box, { flexDirection: 'column', backgroundColor: theme.codeBg },
27
- lines.map((line, i) =>
28
- h(Box, { key: i, flexDirection: 'row', backgroundColor: theme.codeBg },
29
- h(Text, { color: theme.textMuted, backgroundColor: theme.codeBg },
30
+ h(Box, { flexDirection: 'column', backgroundColor: hex.codeBg },
31
+ visible.map((line, i) =>
32
+ h(Box, { key: i, flexDirection: 'row', backgroundColor: hex.codeBg },
33
+ h(Text, { color: hex.textMuted, backgroundColor: hex.codeBg },
30
34
  ' ' + String(i + 1).padStart(lnW) + ' '
31
35
  ),
32
- h(Text, { color: '#C9D1D9', backgroundColor: theme.codeBg, wrap: 'truncate-end' },
33
- line || ' '
36
+ h(Text, { color: '#C9D1D9', backgroundColor: hex.codeBg, wrap: 'truncate-end' },
37
+ (line || ' ').slice(0, cols - 8)
34
38
  )
35
39
  )
36
- )
40
+ ),
41
+ lines.length > maxLines
42
+ ? h(Text, { color: hex.textMuted, backgroundColor: hex.codeBg },
43
+ ' ' + usym.ellipsis + ' ' + (lines.length - maxLines) + ' more lines')
44
+ : null
37
45
  )
38
46
  );
39
47
  }
@@ -1,6 +1,6 @@
1
1
  import React, { useState } from 'react';
2
2
  import { Box, Text, useInput } from 'ink';
3
- import { theme } from '../config/theme.js';
3
+ import { hex, usym } from '../config/theme.js';
4
4
  const { createElement: h } = React;
5
5
 
6
6
  const COMMANDS = [
@@ -8,8 +8,8 @@ const COMMANDS = [
8
8
  { name: '/model', desc: 'Switch model' },
9
9
  { name: '/provider', desc: 'Switch provider' },
10
10
  { name: '/agent', desc: 'Toggle agent mode' },
11
+ { name: '/stop', desc: 'Cancel streaming' },
11
12
  { name: '/clear', desc: 'Clear conversation' },
12
- { name: '/theme', desc: 'Change color theme' },
13
13
  { name: '/export', desc: 'Export conversation' },
14
14
  { name: '/help', desc: 'Show all commands' },
15
15
  { name: '/exit', desc: 'Exit CLARITY' },
@@ -37,29 +37,29 @@ export function CommandPicker({ query, onSelect, onClose }) {
37
37
 
38
38
  return h(Box, { flexDirection: 'column', width: boxWidth },
39
39
  h(Box, { flexDirection: 'row', marginBottom: 1, gap: 1 },
40
- h(Text, { color: theme.textMuted }, '\u2315'),
41
- h(Text, { color: search ? theme.text : theme.textMuted }, search || 'type to filter...'),
40
+ h(Text, { color: hex.textMuted }, usym.bulb),
41
+ h(Text, { color: search ? hex.text : hex.textMuted }, search || 'type to filter...'),
42
42
  ),
43
43
  filtered.map((cmd, i) =>
44
44
  h(Box, {
45
45
  key: cmd.name,
46
46
  flexDirection: 'row',
47
- backgroundColor: i === idx ? theme.selectionBg : undefined,
47
+ backgroundColor: i === idx ? hex.selectionBg : undefined,
48
48
  width: boxWidth,
49
49
  },
50
50
  h(Text, {
51
- color: i === idx ? theme.selectionText : theme.text,
51
+ color: i === idx ? hex.selectionText : hex.text,
52
52
  bold: i === idx,
53
- backgroundColor: i === idx ? theme.selectionBg : undefined,
53
+ backgroundColor: i === idx ? hex.selectionBg : undefined,
54
54
  wrap: 'truncate-end',
55
55
  }, ' ' + cmd.name.padEnd(16)),
56
56
  h(Text, {
57
- color: i === idx ? theme.selectionText : theme.textDim,
58
- backgroundColor: i === idx ? theme.selectionBg : undefined,
57
+ color: i === idx ? hex.selectionText : hex.textDim,
58
+ backgroundColor: i === idx ? hex.selectionBg : undefined,
59
59
  wrap: 'truncate-end',
60
60
  }, cmd.desc)
61
61
  )
62
62
  ),
63
- h(Text, { color: theme.textMuted }, ' \u2191\u2193 nav \u23CE select Esc close')
63
+ h(Text, { color: hex.textMuted }, ' ' + usym.arrowU + usym.arrowD + ' nav Enter select Esc close')
64
64
  );
65
65
  }
@@ -1,93 +1,104 @@
1
- import React, { useState, useRef } from 'react';
1
+ import React, { useState, useCallback, useRef } from 'react';
2
2
  import { Box, Text, useInput } from 'ink';
3
- import { theme } from '../config/theme.js';
4
- const { createElement: h } = React;
3
+ import { hex, usym, u } from '../config/theme.js';
4
+ import { getLayout } from '../config/layout.js';
5
+
6
+ const MAX_VISIBLE_ROWS = 3;
5
7
 
6
8
  export function Composer({ provider, model, agentMode, thinking, onSlash, onSubmit }) {
7
- const [lines, setLines] = useState(['']);
8
- const [cursorLine, setCursorLine] = useState(0);
9
- const [cursorCol, setCursorCol] = useState(0);
10
- const buf = useRef({ lines: [''], line: 0, col: 0 });
11
- const displayName = provider + '/' + model;
9
+ const [input, setInput] = useState('');
10
+ const [cursor, setCursor] = useState(0);
11
+ const inputRef = useRef('');
12
+ inputRef.current = input;
13
+
14
+ const { cols } = getLayout();
15
+ const w = Math.max(10, cols - 6);
16
+ const lineCount = Math.max(1, Math.ceil((input.slice(0, cursor).length + 1) / w));
17
+ const visibleLines = Math.min(lineCount, MAX_VISIBLE_ROWS);
12
18
 
13
- useInput((input, key) => {
14
- const s = buf.current;
19
+ const modelShort = model.replace(/^[^/]+\//, '').slice(0, 18);
15
20
 
16
- if (key.return && !key.shift && !thinking) {
17
- const text = s.lines.join('\n');
18
- s.lines = ['']; s.line = 0; s.col = 0;
19
- setLines(['']); setCursorLine(0); setCursorCol(0);
20
- if (text.trim()) onSubmit(text);
21
+ useInput((ch, key) => {
22
+ if (key.ctrl && key.p) { onSlash(); return; }
23
+ if (key.escape) { onSubmit('/exit'); return; }
24
+ if (key.return && !key.shift) {
25
+ if (input.trim()) {
26
+ const text = input;
27
+ setInput('');
28
+ setCursor(0);
29
+ onSubmit(text);
30
+ }
21
31
  return;
22
32
  }
23
-
24
33
  if (key.return && key.shift) {
25
- const rest = s.lines[s.line].slice(s.col);
26
- s.lines[s.line] = s.lines[s.line].slice(0, s.col);
27
- s.lines.splice(s.line + 1, 0, rest);
28
- s.line++; s.col = 0;
29
- setLines([...s.lines]); setCursorLine(s.line); setCursorCol(0);
34
+ setInput(prev => prev.slice(0, cursor) + '\n' + prev.slice(cursor));
35
+ setCursor(c => c + 1);
30
36
  return;
31
37
  }
32
-
33
38
  if (key.backspace || key.delete) {
34
- if (s.col > 0) {
35
- s.lines[s.line] = s.lines[s.line].slice(0, s.col - 1) + s.lines[s.line].slice(s.col);
36
- s.col--;
37
- } else if (s.line > 0) {
38
- s.col = s.lines[s.line - 1].length;
39
- s.lines[s.line - 1] += s.lines[s.line];
40
- s.lines.splice(s.line, 1);
41
- s.line--;
39
+ if (cursor > 0) {
40
+ setInput(prev => prev.slice(0, cursor - 1) + prev.slice(cursor));
41
+ setCursor(c => c - 1);
42
42
  }
43
- setLines([...s.lines]); setCursorLine(s.line); setCursorCol(s.col);
44
43
  return;
45
44
  }
46
-
47
- if (key.upArrow && s.line > 0) { s.line--; s.col = Math.min(s.col, s.lines[s.line].length); setCursorLine(s.line); setCursorCol(s.col); return; }
48
- if (key.downArrow && s.line < s.lines.length - 1) { s.line++; s.col = Math.min(s.col, s.lines[s.line].length); setCursorLine(s.line); setCursorCol(s.col); return; }
49
- if (key.leftArrow) { if (s.col > 0) s.col--; else if (s.line > 0) { s.line--; s.col = s.lines[s.line].length; } setCursorLine(s.line); setCursorCol(s.col); return; }
50
- if (key.rightArrow) { if (s.col < s.lines[s.line].length) s.col++; else if (s.line < s.lines.length - 1) { s.line++; s.col = 0; } setCursorLine(s.line); setCursorCol(s.col); return; }
51
-
52
- if (input && input.length === 1 && !key.ctrl && !key.meta) {
53
- if (input === '/' && s.lines.length === 1 && s.lines[0] === '') { onSlash?.(); return; }
54
- s.lines[s.line] = s.lines[s.line].slice(0, s.col) + input + s.lines[s.line].slice(s.col);
55
- s.col++;
56
- setLines([...s.lines]); setCursorLine(s.line); setCursorCol(s.col);
45
+ if (key.leftArrow && cursor > 0) { setCursor(c => c - 1); return; }
46
+ if (key.rightArrow && cursor < input.length) { setCursor(c => c + 1); return; }
47
+ if (key.home) { setCursor(0); return; }
48
+ if (key.end) { setCursor(input.length); return; }
49
+ if (ch && ch.length === 1 && ch.charCodeAt(0) >= 32) {
50
+ setInput(prev => prev.slice(0, cursor) + ch + prev.slice(cursor));
51
+ setCursor(c => c + 1);
57
52
  }
58
53
  });
59
54
 
60
- const dispLines = lines.join('\n') ? lines : [''];
61
- const height = Math.min(dispLines.length, 5);
55
+ const displayText = input || (thinking ? '' : 'Type a message...');
56
+ const isPlaceholder = !input && !thinking;
57
+
58
+ const rows = [];
59
+ rows.push(
60
+ Box({ key: 'dock_header', height: 1, backgroundColor: hex.surfaceAlt },
61
+ Text({ color: hex.textMuted, backgroundColor: hex.surfaceAlt }, ' ' + u.h.repeat(Math.max(0, cols - 2)))
62
+ )
63
+ );
64
+
65
+ const inputRows = [];
66
+ for (let i = 0; i < visibleLines; i++) {
67
+ const start = i * w;
68
+ const end = start + w;
69
+ const seg = displayText.slice(start, end);
70
+ inputRows.push(
71
+ Box({ key: 'in' + i, height: 1, backgroundColor: hex.bg },
72
+ Text({
73
+ color: isPlaceholder ? hex.textMuted : hex.text,
74
+ backgroundColor: hex.bg,
75
+ wrap: 'truncate-end',
76
+ }, ' ' + usym.triR2 + ' ' + (seg || ' '))
77
+ )
78
+ );
79
+ }
62
80
 
63
- return h(Box, { flexDirection: 'column', flexShrink: 0, borderStyle: 'round', borderColor: theme.border },
64
- h(Box, { flexDirection: 'column' },
65
- dispLines.slice(0, height).map((line, i) =>
66
- h(Box, { key: i, flexDirection: 'row', backgroundColor: theme.surface },
67
- h(Text, { color: theme.accent, backgroundColor: theme.surface },
68
- i === cursorLine ? '\u276F' : ' '
69
- ),
70
- h(Text, { color: theme.text, backgroundColor: theme.surface, wrap: 'wrap' },
71
- ' ' + (line || ' ')
72
- ),
73
- i === cursorLine
74
- ? h(Text, { color: theme.info, backgroundColor: theme.surface }, '\u258C')
75
- : h(Text, { backgroundColor: theme.surface }, ' ')
76
- )
81
+ for (let i = visibleLines; i < MAX_VISIBLE_ROWS + 1; i++) {
82
+ if (i === MAX_VISIBLE_ROWS + 0) break;
83
+ inputRows.push(
84
+ Box({ key: 'in_fill' + i, height: 1, backgroundColor: hex.bg },
85
+ Text({ color: hex.textMuted, backgroundColor: hex.bg }, ' ' + usym.lightV)
77
86
  )
78
- ),
79
- h(Box, { flexDirection: 'row', gap: 1, paddingX: 1, backgroundColor: theme.surfaceAlt },
80
- h(Text, { color: theme.textMuted }, '\u2502'),
81
- h(Text, { color: theme.info }, '\u25C9 ' + displayName),
82
- h(Text, { color: theme.textMuted }, '\u00B7'),
83
- h(Text, { color: agentMode ? theme.success : theme.textMuted },
84
- agentMode ? '\u25C9 agent' : '\u25CB agent'
85
- ),
86
- h(Text, { color: theme.textMuted }, '\u00B7'),
87
- h(Text, { color: theme.textDim }, 'Ctrl+P'),
88
- thinking
89
- ? h(Text, { color: theme.warning }, ' \u25CF thinking')
90
- : null,
87
+ );
88
+ }
89
+
90
+ const statusLine = Box({ key: 'dock_status', height: 1, backgroundColor: hex.surfaceAlt },
91
+ Text({ color: hex.textMuted, backgroundColor: hex.surfaceAlt },
92
+ ' ' + provider + ' ' + usym.midDot + ' ' + modelShort +
93
+ (agentMode ? ' ' + usym.midDot + ' Agent' : '') +
94
+ ' ' + usym.midDot + ' Ctrl+P commands'
91
95
  )
92
96
  );
97
+
98
+ rows.push(...inputRows);
99
+ rows.push(statusLine);
100
+
101
+ return Box({ flexDirection: 'column', backgroundColor: hex.surfaceAlt },
102
+ ...rows
103
+ );
93
104
  }