novacode 0.5.3 → 0.6.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/src/tui/app.tsx CHANGED
@@ -1,13 +1,17 @@
1
1
  import chalk from "chalk"
2
- import { Box, render, Static, Text, useInput } from "ink"
2
+ import { Box, render, Static, Text, useApp, useInput } from "ink"
3
3
  import { useCallback, useEffect, useRef, useState } from "react"
4
4
  import type { Agent } from "../agent/agent.ts"
5
5
  import { COMMANDS, dispatch } from "../commands/index.ts"
6
+ import { getProvider, MODELS } from "../config/providers.ts"
7
+ import { loadAuth } from "../config/store.ts"
8
+ import { generateSessionTitle } from "../session/compact.ts"
6
9
  import type { SessionStore } from "../session/store.ts"
7
10
  import type { Msg, Prompts } from "../types.ts"
8
11
  import { checkForUpdate, getCurrentVersion } from "../update.ts"
9
- import { formatToolArgs } from "../util.ts"
10
- import { formatMarkdown } from "./markdown.ts"
12
+ import { Cursor, LiveArea } from "./components/liveArea.tsx"
13
+ import { hasMeaningfulContent, Message } from "./components/message.tsx"
14
+ import { StatusBar } from "./components/statusBar.tsx"
11
15
  import { ConfirmPrompt, PasswordPrompt, SelectPrompt } from "./prompts.tsx"
12
16
 
13
17
  type PromptMode =
@@ -35,57 +39,61 @@ export async function interactive(
35
39
  process.stdout.write(`${chalk.cyan.bold("⚡ novacode")} ${chalk.gray(`v${version}`)}\n`)
36
40
 
37
41
  try {
38
- const { waitUntilExit } = render(<App agent={agent} store={store} sessionId={sessionId} />)
42
+ const { waitUntilExit } = render(<App agent={agent} store={store} sessionId={sessionId} />, {
43
+ exitOnCtrlC: false,
44
+ })
39
45
  await waitUntilExit()
40
46
  } finally {
41
47
  process.stdout.write("\x1B[?25h")
48
+ await store.prune()
42
49
  }
43
50
  }
44
51
 
45
- const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
46
-
47
- function Spinner() {
48
- const [frame, setFrame] = useState(0)
49
-
50
- useEffect(() => {
51
- const timer = setInterval(() => {
52
- setFrame((f) => (f + 1) % SPINNER_FRAMES.length)
53
- }, 80)
54
- return () => clearInterval(timer)
55
- }, [])
56
-
57
- return <Text color="yellow">{SPINNER_FRAMES[frame]}</Text>
58
- }
59
-
60
- function Cursor() {
61
- const [visible, setVisible] = useState(true)
62
- useEffect(() => {
63
- const timer = setInterval(() => setVisible((v) => !v), 530)
64
- return () => clearInterval(timer)
65
- }, [])
66
- return <Text color="green">{visible ? "│" : " "}</Text>
67
- }
68
-
69
52
  function App({
70
- agent: initialAgent,
53
+ agent,
71
54
  store,
72
- sessionId,
55
+ sessionId: initialSessionId,
73
56
  }: {
74
57
  agent: Agent
75
58
  store: SessionStore
76
59
  sessionId: string
77
60
  }) {
78
- const [agent, _setAgent] = useState(initialAgent)
79
- const [msgs, setMsgs] = useState<Msg[]>(initialAgent.messages)
61
+ const [currSessionId, setCurrSessionId] = useState(initialSessionId)
62
+ const [msgs, setMsgs] = useState<Msg[]>(agent.messages)
63
+
64
+ const handleSwitchSession = useCallback(
65
+ async (newSessionId: string) => {
66
+ const s = await store.get(newSessionId)
67
+ if (!s) return
68
+
69
+ const provider = getProvider(s.provider)
70
+ const model =
71
+ MODELS.find((m) => m.id === s.model && m.provider === s.provider) ||
72
+ MODELS.find((m) => m.id === s.model)
73
+ if (provider && model) {
74
+ const auth = await loadAuth()
75
+ const apiKey = auth.apiKeys[s.provider] || ""
76
+ agent.updateConfig({
77
+ api: provider.api,
78
+ model,
79
+ apiKey,
80
+ baseUrl: provider.baseUrl,
81
+ })
82
+ }
83
+
84
+ const newMsgs = await store.messages(newSessionId)
85
+ agent.setMessages(newMsgs)
86
+ setMsgs(newMsgs)
87
+ setCurrSessionId(newSessionId)
88
+ },
89
+ [store, agent],
90
+ )
80
91
  const [stream, setStream] = useState("")
81
92
  const [thinkStream, setThinkStream] = useState("")
82
93
  const [busy, setBusy] = useState(false)
83
94
  const [input, setInput] = useState("")
84
95
  const [status, setStatus] = useState("")
85
- const [usage, setUsage] = useState<{ in: number; out: number }>({
86
- in: 0,
87
- out: 0,
88
- })
96
+ const [usage, setUsage] = useState<{ in: number; out: number }>({ in: 0, out: 0 })
89
97
  const [selCmdIdx, setSelCmdIdx] = useState(0)
90
98
  const [mode, setMode] = useState<PromptMode>({ type: "chat" })
91
99
  const resolveRef = useRef<((v: unknown) => void) | null>(null)
@@ -96,6 +104,9 @@ function App({
96
104
  current: string
97
105
  latest: string
98
106
  } | null>(null)
107
+ const { exit } = useApp()
108
+ const lastExitPress = useRef<{ key: "C"; ts: number } | null>(null)
109
+ const [exitConfirmKey, setExitConfirmKey] = useState<"C" | null>(null)
99
110
 
100
111
  useEffect(() => {
101
112
  const check = async () => {
@@ -153,7 +164,9 @@ function App({
153
164
  function commitMsg(msg: Msg) {
154
165
  setMsgs((prev) => [...prev, msg])
155
166
  agent.setMessages([...agent.messages, msg])
156
- store.append(sessionId, msg)
167
+ store.append(currSessionId, msg).catch((err) => {
168
+ console.error("Error appending message to session store:", err)
169
+ })
157
170
  }
158
171
 
159
172
  // biome-ignore lint/correctness/useExhaustiveDependencies: reset selection on input change
@@ -162,12 +175,53 @@ function App({
162
175
  }, [input])
163
176
 
164
177
  useInput((ch, key) => {
178
+ if (key.ctrl && (ch === "c" || ch === "d")) {
179
+ if (busy) {
180
+ if (ch === "c") {
181
+ if (abortCtrl.current) {
182
+ abortCtrl.current.abort()
183
+ abortCtrl.current = null
184
+ }
185
+ }
186
+ return
187
+ }
188
+
189
+ // Idle state - handle exit
190
+ if (ch === "d") {
191
+ exit()
192
+ return
193
+ }
194
+
195
+ // Ctrl+C double-press exit logic
196
+ const now = Date.now()
197
+ if (
198
+ lastExitPress.current &&
199
+ lastExitPress.current.key === "C" &&
200
+ now - lastExitPress.current.ts < 2000
201
+ ) {
202
+ exit()
203
+ } else {
204
+ lastExitPress.current = { key: "C", ts: now }
205
+ setExitConfirmKey("C")
206
+ // Clear the temporary status after 2 seconds
207
+ setTimeout(() => {
208
+ if (lastExitPress.current?.key === "C" && Date.now() - lastExitPress.current.ts >= 2000) {
209
+ lastExitPress.current = null
210
+ setExitConfirmKey(null)
211
+ }
212
+ }, 2000)
213
+ }
214
+ return
215
+ }
216
+
165
217
  if (mode.type !== "chat") return
166
218
 
167
219
  if (key.escape) {
168
220
  if (abortCtrl.current) {
169
221
  abortCtrl.current.abort()
170
222
  abortCtrl.current = null
223
+ } else if (input) {
224
+ setInput("")
171
225
  }
172
226
  return
173
227
  }
@@ -225,7 +279,7 @@ function App({
225
279
  hIdx.current = -1
226
280
 
227
281
  if (line.startsWith("/")) {
228
- dispatch(line, agent, store, sessionId, prompts).then((r) => {
282
+ dispatch(line, agent, store, currSessionId, prompts, exit, handleSwitchSession).then((r) => {
229
283
  if (r) {
230
284
  commitMsg({
231
285
  role: "assistant",
@@ -241,7 +295,6 @@ function App({
241
295
  return
242
296
  }
243
297
 
244
- // Record user message before starting the stream
245
298
  const userMsg: Msg = { role: "user", content: line, ts: Date.now() }
246
299
  commitMsg(userMsg)
247
300
 
@@ -269,10 +322,9 @@ function App({
269
322
  break
270
323
  case "assistant_msg":
271
324
  commitMsg(ev.msg)
272
- setTimeout(() => {
273
- setStream("")
274
- setThinkStream("")
275
- }, 0)
325
+ setStream("")
326
+ setThinkStream("")
327
+
276
328
  break
277
329
  case "tool_call":
278
330
  setStatus(chalk.dim(`⏳ ${ev.call.name}…`))
@@ -287,6 +339,20 @@ function App({
287
339
  break
288
340
  case "turn_end":
289
341
  setStatus("")
342
+ store
343
+ .get(currSessionId)
344
+ .then((s) => {
345
+ if (s && !s.title && agent.messages.length >= 2) {
346
+ generateSessionTitle(agent.messages, agent.model, agent.apiKey, agent.baseUrl)
347
+ .then((title) => {
348
+ if (title) {
349
+ store.setTitle(currSessionId, title).catch(() => {})
350
+ }
351
+ })
352
+ .catch(() => {})
353
+ }
354
+ })
355
+ .catch(() => {})
290
356
  break
291
357
  case "usage":
292
358
  if (ev.usage) setUsage(ev.usage)
@@ -336,41 +402,12 @@ function App({
336
402
 
337
403
  return (
338
404
  <Box flexDirection="column" paddingX={1}>
339
- {/* Messages - pushed to scrollback as they finish */}
340
405
  <Static items={visibleMsgs}>
341
406
  {(m, i) => <Message key={`${m.ts}-${i}`} msg={m} isFirst={i === 0} />}
342
407
  </Static>
343
408
 
344
- {/* Live Area (Streaming, active tool calls, working indicator) */}
345
- {isLiveActive && (
346
- <Box flexDirection="column" marginTop={visibleMsgs.length > 0 ? 1 : 0}>
347
- {thinkStream && (
348
- <Text dimColor italic>
349
- {thinkStream}
350
- </Text>
351
- )}
352
- {stream && (
353
- <Box flexDirection="row">
354
- <Box flexGrow={1} flexShrink={1}>
355
- <Text>
356
- {formatMarkdown(stream)}
357
- {!input && <Cursor />}
358
- </Text>
359
- </Box>
360
- </Box>
361
- )}
362
- {busy && !stream && (
363
- <Box flexDirection="row">
364
- <Box marginRight={1}>
365
- <Spinner />
366
- </Box>
367
- <Text dimColor>{status ? status.replace("⏳ ", "") : chalk.yellow("working…")}</Text>
368
- </Box>
369
- )}
370
- </Box>
371
- )}
409
+ <LiveArea stream={stream} thinkStream={thinkStream} busy={busy} status={status} />
372
410
 
373
- {/* Input & Footer (Live) */}
374
411
  <Box flexDirection="column" marginTop={visibleMsgs.length > 0 || isLiveActive ? 1 : 0}>
375
412
  {updateInfo && (
376
413
  <Box
@@ -403,155 +440,15 @@ function App({
403
440
  </Box>
404
441
  </Box>
405
442
 
406
- {/* Dynamic Status / Info Line */}
407
- <Box justifyContent="space-between">
408
- <Box>
409
- {suggestions.length > 0 ? (
410
- <Box flexDirection="column" marginLeft={2}>
411
- {suggestions.map((s, i) => (
412
- <Box key={s.name}>
413
- <Text
414
- color={i === selCmdIdx ? "black" : "yellow"}
415
- backgroundColor={i === selCmdIdx ? "yellow" : undefined}
416
- >
417
- /{s.name.padEnd(10)}
418
- </Text>
419
- <Text dimColor> {s.desc}</Text>
420
- </Box>
421
- ))}
422
- </Box>
423
- ) : (
424
- <Text dimColor>Enter to send · /help for commands</Text>
425
- )}
426
- </Box>
427
-
428
- <Box>
429
- <Text dimColor>{formatTokenUsage(usage.in, agent.model.contextWindow)}</Text>
430
- <Text dimColor> │ </Text>
431
- <Text dimColor>{agent.model.id}</Text>
432
- {busy && <Text dimColor> │ Esc to stop</Text>}
433
- </Box>
434
- </Box>
443
+ <StatusBar
444
+ model={agent.model}
445
+ usage={usage}
446
+ busy={busy}
447
+ suggestions={suggestions}
448
+ selCmdIdx={selCmdIdx}
449
+ exitConfirmKey={exitConfirmKey}
450
+ />
435
451
  </Box>
436
452
  </Box>
437
453
  )
438
454
  }
439
-
440
- function fmtK(n: number): string {
441
- const k = n / 1000
442
- return k >= 100 ? `${Math.round(k)}K` : `${k.toFixed(1)}K`
443
- }
444
-
445
- function formatTokenUsage(used: number, contextWindow: number): string {
446
- if (used === 0) return `0/${fmtK(contextWindow)}`
447
- const pct = Math.round((used / contextWindow) * 100)
448
- return `${fmtK(used)}/${fmtK(contextWindow)} (${pct}%)`
449
- }
450
-
451
- const TOOL_STYLE: Record<string, string> = {
452
- read: "blue",
453
- write: "magenta",
454
- edit: "yellow",
455
- bash: "cyan",
456
- glob: "green",
457
- find: "green",
458
- grep: "green",
459
- tree: "green",
460
- }
461
-
462
- function hasMeaningfulContent(msg: Msg): boolean {
463
- if (msg.role === "user") return true
464
- if (msg.role === "tool_result") return true
465
- if (msg.role === "assistant") {
466
- if (msg.model === "system") return true
467
- return msg.content.some((c) => {
468
- if (c.type === "thinking") return c.text.trim().length > 0
469
- if (c.type === "text") return c.text.trim().length > 0
470
- return false
471
- })
472
- }
473
- return false
474
- }
475
-
476
- function Message({ msg, isFirst }: { msg: Msg; isFirst: boolean }) {
477
- if (msg.role === "user") {
478
- return (
479
- <Box marginTop={isFirst ? 0 : 1} flexDirection="row">
480
- <Box flexShrink={0} marginRight={1}>
481
- <Text bold color="green">
482
- {">"}
483
- </Text>
484
- </Box>
485
- <Box flexGrow={1} flexShrink={1}>
486
- <Text>
487
- {typeof msg.content === "string"
488
- ? msg.content
489
- : msg.content.map((c) => (c.type === "text" ? c.text : "")).join("")}
490
- </Text>
491
- </Box>
492
- </Box>
493
- )
494
- }
495
-
496
- if (msg.role === "assistant") {
497
- if (msg.model === "system") {
498
- return (
499
- <Box flexDirection="column" marginTop={0}>
500
- {msg.content.map((c, i) =>
501
- // biome-ignore lint/suspicious/noArrayIndexKey: stable turn content
502
- c.type === "text" ? <Text key={i}>{formatMarkdown(c.text)}</Text> : null,
503
- )}
504
- </Box>
505
- )
506
- }
507
-
508
- const hasVisibleContent = msg.content.some((c) => c.type === "text" || c.type === "thinking")
509
- if (!hasVisibleContent) return null
510
-
511
- return (
512
- <Box flexDirection="column" marginTop={0}>
513
- {msg.content.map((c, i) => {
514
- if (c.type === "thinking") {
515
- return (
516
- // biome-ignore lint/suspicious/noArrayIndexKey: stable turn content
517
- <Text key={i} dimColor italic>
518
- {c.text}
519
- </Text>
520
- )
521
- }
522
- if (c.type === "text") {
523
- // biome-ignore lint/suspicious/noArrayIndexKey: stable turn content
524
- return <Text key={i}>{formatMarkdown(c.text)}</Text>
525
- }
526
- return null
527
- })}
528
- </Box>
529
- )
530
- }
531
-
532
- if (msg.role === "tool_result") {
533
- const args = msg.args ? formatToolArgs(msg.args, true) : ""
534
-
535
- const resText = msg.content
536
- .map((c) => (c.type === "text" ? c.text : ""))
537
- .join("")
538
- .trim()
539
-
540
- const isRead = msg.tool === "read"
541
- const lineCount = isRead ? resText.split("\n").length : 0
542
- const color = TOOL_STYLE[msg.tool] || "white"
543
-
544
- return (
545
- <Box flexDirection="row">
546
- <Text color={msg.isError ? "red" : "green"}>{msg.isError ? "✗" : "✓"} </Text>
547
- <Text color={color} bold>
548
- {msg.tool}
549
- </Text>
550
- {args && <Text> {args}</Text>}
551
- {isRead && !msg.isError && <Text dimColor> ({lineCount} lines)</Text>}
552
- {msg.isError && resText && <Text color="red"> {resText.slice(0, 80)}</Text>}
553
- </Box>
554
- )
555
- }
556
- return null
557
- }
@@ -0,0 +1,70 @@
1
+ import chalk from "chalk"
2
+ import { Box, Text } from "ink"
3
+ import { useEffect, useState } from "react"
4
+ import { SPINNER_FRAMES } from "../constants.ts"
5
+ import { formatMarkdown } from "../markdown.ts"
6
+
7
+ export function Spinner() {
8
+ const [frame, setFrame] = useState(0)
9
+
10
+ useEffect(() => {
11
+ const timer = setInterval(() => {
12
+ setFrame((f) => (f + 1) % SPINNER_FRAMES.length)
13
+ }, 80)
14
+ return () => clearInterval(timer)
15
+ }, [])
16
+
17
+ return <Text color="yellow">{SPINNER_FRAMES[frame]}</Text>
18
+ }
19
+
20
+ export function Cursor() {
21
+ const [visible, setVisible] = useState(true)
22
+ useEffect(() => {
23
+ const timer = setInterval(() => setVisible((v) => !v), 530)
24
+ return () => clearInterval(timer)
25
+ }, [])
26
+ return <Text color="green">{visible ? "│" : " "}</Text>
27
+ }
28
+
29
+ export function LiveArea({
30
+ stream,
31
+ thinkStream,
32
+ busy,
33
+ status,
34
+ }: {
35
+ stream: string
36
+ thinkStream: string
37
+ busy: boolean
38
+ status: string
39
+ }) {
40
+ const isActive = !!(stream || thinkStream || busy)
41
+ if (!isActive) return null
42
+
43
+ return (
44
+ <Box flexDirection="column" marginTop={0}>
45
+ {thinkStream && (
46
+ <Text dimColor italic>
47
+ {thinkStream}
48
+ </Text>
49
+ )}
50
+ {stream && (
51
+ <Box flexDirection="row">
52
+ <Box flexGrow={1} flexShrink={1}>
53
+ <Text>
54
+ {formatMarkdown(stream)}
55
+ <Cursor />
56
+ </Text>
57
+ </Box>
58
+ </Box>
59
+ )}
60
+ {busy && !stream && (
61
+ <Box flexDirection="row">
62
+ <Box marginRight={1}>
63
+ <Spinner />
64
+ </Box>
65
+ <Text dimColor>{status ? status.replace("⏳ ", "") : chalk.yellow("working…")}</Text>
66
+ </Box>
67
+ )}
68
+ </Box>
69
+ )
70
+ }
@@ -0,0 +1,117 @@
1
+ import { Box, Text } from "ink"
2
+ import type { Msg } from "../../types.ts"
3
+ import { formatToolArgs } from "../../util.ts"
4
+ import { TERMINATION_PHRASES, TOOL_STYLE } from "../constants.ts"
5
+ import { formatMarkdown } from "../markdown.ts"
6
+
7
+ export function hasMeaningfulContent(msg: Msg): boolean {
8
+ if (msg.role === "user") return true
9
+ if (msg.role === "tool_result") return true
10
+ if (msg.role === "assistant") {
11
+ if (msg.model === "system") return true
12
+ if (msg.stop === "aborted") return true
13
+ return msg.content.some((c) => {
14
+ if (c.type === "thinking") return c.text.trim().length > 0
15
+ if (c.type === "text") return c.text.trim().length > 0
16
+ return false
17
+ })
18
+ }
19
+ return false
20
+ }
21
+
22
+ export function Message({ msg, isFirst }: { msg: Msg; isFirst: boolean }) {
23
+ if (msg.role === "user") {
24
+ return (
25
+ <Box marginTop={isFirst ? 0 : 1} flexDirection="row">
26
+ <Box flexShrink={0} marginRight={1}>
27
+ <Text bold color="green">
28
+ {">"}
29
+ </Text>
30
+ </Box>
31
+ <Box flexGrow={1} flexShrink={1}>
32
+ <Text>
33
+ {typeof msg.content === "string"
34
+ ? msg.content
35
+ : msg.content.map((c) => (c.type === "text" ? c.text : "")).join("")}
36
+ </Text>
37
+ </Box>
38
+ </Box>
39
+ )
40
+ }
41
+
42
+ if (msg.role === "assistant") {
43
+ if (msg.model === "system") {
44
+ return (
45
+ <Box flexDirection="column" marginTop={0}>
46
+ {msg.content.map((c, i) =>
47
+ // biome-ignore lint/suspicious/noArrayIndexKey: stable turn content
48
+ c.type === "text" ? <Text key={i}>{formatMarkdown(c.text)}</Text> : null,
49
+ )}
50
+ </Box>
51
+ )
52
+ }
53
+
54
+ const isAborted = msg.stop === "aborted"
55
+ const hasVisibleContent =
56
+ isAborted || msg.content.some((c) => c.type === "text" || c.type === "thinking")
57
+ if (!hasVisibleContent) return null
58
+
59
+ const termPhrase = isAborted
60
+ ? (TERMINATION_PHRASES[msg.ts % TERMINATION_PHRASES.length] ?? "Terminated by user")
61
+ : ""
62
+
63
+ return (
64
+ <Box flexDirection="column" marginTop={0}>
65
+ {msg.content.map((c, i) => {
66
+ if (c.type === "thinking") {
67
+ return (
68
+ // biome-ignore lint/suspicious/noArrayIndexKey: stable turn content
69
+ <Text key={i} dimColor italic>
70
+ {c.text}
71
+ </Text>
72
+ )
73
+ }
74
+ if (c.type === "text") {
75
+ // biome-ignore lint/suspicious/noArrayIndexKey: stable turn content
76
+ return <Text key={i}>{formatMarkdown(c.text)}</Text>
77
+ }
78
+ return null
79
+ })}
80
+ {isAborted && (
81
+ <Box marginTop={0}>
82
+ <Text color="red" italic>
83
+ ▲ {termPhrase}
84
+ </Text>
85
+ </Box>
86
+ )}
87
+ </Box>
88
+ )
89
+ }
90
+
91
+ if (msg.role === "tool_result") {
92
+ const args = msg.args ? formatToolArgs(msg.args, true) : ""
93
+
94
+ const resText = msg.content
95
+ .map((c) => (c.type === "text" ? c.text : ""))
96
+ .join("")
97
+ .trim()
98
+
99
+ const isRead = msg.tool === "read"
100
+ const lineCount = isRead ? resText.split("\n").length : 0
101
+ const color = TOOL_STYLE[msg.tool] || "white"
102
+
103
+ return (
104
+ <Box flexDirection="row">
105
+ <Text color={msg.isError ? "red" : "green"}>{msg.isError ? "✗" : "✓"} </Text>
106
+ <Text color={color} bold>
107
+ {msg.tool}
108
+ </Text>
109
+ {args && <Text> {args}</Text>}
110
+ {isRead && !msg.isError && <Text dimColor> ({lineCount} lines)</Text>}
111
+ {msg.isError && resText && <Text color="red"> {resText.slice(0, 80)}</Text>}
112
+ </Box>
113
+ )
114
+ }
115
+
116
+ return null
117
+ }
@@ -0,0 +1,64 @@
1
+ import { Box, Text } from "ink"
2
+ import type { Model } from "../../types.ts"
3
+
4
+ function fmtK(n: number): string {
5
+ const k = n / 1000
6
+ return k >= 100 ? `${Math.round(k)}K` : `${k.toFixed(1)}K`
7
+ }
8
+
9
+ function formatTokenUsage(used: number, contextWindow: number): string {
10
+ if (used === 0) return `0/${fmtK(contextWindow)}`
11
+ const pct = Math.round((used / contextWindow) * 100)
12
+ return `${fmtK(used)}/${fmtK(contextWindow)} (${pct}%)`
13
+ }
14
+
15
+ export function StatusBar({
16
+ model,
17
+ usage,
18
+ busy,
19
+ suggestions,
20
+ selCmdIdx,
21
+ exitConfirmKey,
22
+ }: {
23
+ model: Model
24
+ usage: { in: number; out: number }
25
+ busy: boolean
26
+ suggestions: Array<{ name: string; desc: string }>
27
+ selCmdIdx: number
28
+ exitConfirmKey: "C" | null
29
+ }) {
30
+ return (
31
+ <Box justifyContent="space-between">
32
+ <Box>
33
+ {suggestions.length > 0 ? (
34
+ <Box flexDirection="column" marginLeft={2}>
35
+ {suggestions.map((s, i) => (
36
+ <Box key={s.name}>
37
+ <Text
38
+ color={i === selCmdIdx ? "black" : "yellow"}
39
+ backgroundColor={i === selCmdIdx ? "yellow" : undefined}
40
+ >
41
+ /{s.name.padEnd(10)}
42
+ </Text>
43
+ <Text dimColor> {s.desc}</Text>
44
+ </Box>
45
+ ))}
46
+ </Box>
47
+ ) : exitConfirmKey === "C" ? (
48
+ <Text color="yellow">Press Ctrl+C again to exit</Text>
49
+ ) : busy ? (
50
+ <Text dimColor>Press Esc to abort or terminate</Text>
51
+ ) : (
52
+ <Text dimColor>Enter to send · /help for commands</Text>
53
+ )}
54
+ </Box>
55
+
56
+ <Box>
57
+ <Text dimColor>{formatTokenUsage(usage.in, model.contextWindow)}</Text>
58
+ <Text dimColor> │ </Text>
59
+ <Text dimColor>{model.id}</Text>
60
+ {busy && <Text dimColor> │ Esc to stop</Text>}
61
+ </Box>
62
+ </Box>
63
+ )
64
+ }