novacode 0.5.5 → 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.
@@ -1,229 +1,209 @@
1
- import { unlinkSync } from "node:fs"
1
+ import { appendFile, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"
2
2
  import { join } from "node:path"
3
- import BetterSqlite3 from "better-sqlite3"
4
- import type { Msg, Session } from "../types.ts"
5
-
6
- const SCHEMA = `
7
- CREATE TABLE IF NOT EXISTS sessions (
8
- id TEXT PRIMARY KEY,
9
- cwd TEXT NOT NULL,
10
- model TEXT NOT NULL,
11
- provider TEXT NOT NULL,
12
- title TEXT,
13
- created INTEGER NOT NULL,
14
- updated INTEGER NOT NULL
15
- );
16
-
17
- CREATE TABLE IF NOT EXISTS messages (
18
- id INTEGER PRIMARY KEY AUTOINCREMENT,
19
- session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
20
- seq INTEGER NOT NULL,
21
- role TEXT NOT NULL,
22
- content TEXT NOT NULL,
23
- ts INTEGER NOT NULL
24
- );
25
-
26
- CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, seq);
27
-
28
- CREATE TABLE IF NOT EXISTS compactions (
29
- id INTEGER PRIMARY KEY AUTOINCREMENT,
30
- session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
31
- summary TEXT NOT NULL,
32
- files_read TEXT NOT NULL DEFAULT '[]',
33
- files_wrote TEXT NOT NULL DEFAULT '[]',
34
- seq_before INTEGER NOT NULL,
35
- ts INTEGER NOT NULL
36
- );
37
- `
3
+ import type { Compaction, Msg, Session } from "../types.ts"
38
4
 
39
5
  function generateId(): string {
40
6
  return `${Date.now().toString(36)}-${crypto.randomUUID().slice(0, 8)}`
41
7
  }
42
8
 
43
9
  export class SessionStore {
44
- #db: BetterSqlite3.Database
10
+ #sessionsDir: string
45
11
 
46
- constructor(dbPath: string) {
47
- this.#db = SessionStore.#open(dbPath)
12
+ constructor(sessionsDir: string) {
13
+ this.#sessionsDir = sessionsDir
48
14
  }
49
15
 
50
- // Opens and fully initialises the DB. If anything throws (e.g. corrupt file),
51
- // the bad file is deleted and a fresh DB is created and returned.
52
- static #open(dbPath: string): BetterSqlite3.Database {
53
- const init = (db: BetterSqlite3.Database) => {
54
- db.pragma("journal_mode = WAL")
55
- db.pragma("foreign_keys = ON")
56
- db.exec(SCHEMA)
57
- return db
16
+ #sessionDir(id: string): string {
17
+ return join(this.#sessionsDir, id)
18
+ }
19
+
20
+ #metadataPath(id: string): string {
21
+ return join(this.#sessionDir(id), "metadata.json")
22
+ }
23
+
24
+ #messagesPath(id: string): string {
25
+ return join(this.#sessionDir(id), "messages.jsonl")
26
+ }
27
+
28
+ #compactionPath(id: string): string {
29
+ return join(this.#sessionDir(id), "compaction.json")
30
+ }
31
+
32
+ async create(cwd: string, model: string, provider: string): Promise<Session> {
33
+ const id = generateId()
34
+ const now = Date.now()
35
+ const session: Session = {
36
+ id,
37
+ cwd,
38
+ model,
39
+ provider,
40
+ title: null,
41
+ created: now,
42
+ updated: now,
58
43
  }
44
+
45
+ await mkdir(this.#sessionDir(id), { recursive: true })
46
+ await writeFile(this.#metadataPath(id), JSON.stringify(session, null, 2))
47
+ return session
48
+ }
49
+
50
+ async get(id: string): Promise<Session | null> {
59
51
  try {
60
- return init(new BetterSqlite3(dbPath))
52
+ const data = await readFile(this.#metadataPath(id), "utf-8")
53
+ return JSON.parse(data) as Session
61
54
  } catch {
62
- // Delete the main DB and WAL sidecar files — all three must go or
63
- // SQLite will fail again trying to replay a corrupt WAL on reopen.
64
- for (const f of [dbPath, `${dbPath}-wal`, `${dbPath}-shm`]) {
65
- try {
66
- unlinkSync(f)
67
- } catch {
68
- // file may already be absent — ignore
69
- }
70
- }
71
- return init(new BetterSqlite3(dbPath))
55
+ return null
72
56
  }
73
57
  }
74
58
 
75
- create(cwd: string, model: string, provider: string): Session {
76
- const id = generateId()
77
- const now = Date.now()
78
- this.#db
79
- .prepare(
80
- "INSERT INTO sessions (id, cwd, model, provider, title, created, updated) VALUES ($id, $cwd, $model, $provider, $title, $created, $updated)",
81
- )
82
- .run({
83
- id: id,
84
- cwd: cwd,
85
- model: model,
86
- provider: provider,
87
- title: null,
88
- created: now,
89
- updated: now,
90
- })
91
- return { id, cwd, model, provider, title: null, created: now, updated: now }
92
- }
93
-
94
- get(id: string): Session | null {
95
- return (
96
- (this.#db
97
- .prepare(
98
- "SELECT id, cwd, model, provider, title, created, updated FROM sessions WHERE id = $id",
99
- )
100
- .get({ id: id }) as Session | null) ?? null
101
- )
102
- }
103
-
104
- list(limit = 50): Session[] {
105
- return this.#db
106
- .prepare(
107
- "SELECT id, cwd, model, provider, title, created, updated FROM sessions ORDER BY updated DESC LIMIT $limit",
108
- )
109
- .all({ limit: limit }) as Session[]
110
- }
111
-
112
- delete(id: string): boolean {
113
- const result = this.#db.prepare("DELETE FROM sessions WHERE id = $id").run({ id: id })
114
- return result.changes > 0
115
- }
116
-
117
- append(sessionId: string, msg: Msg): void {
118
- const seq = this.#nextSeq(sessionId)
119
- this.#db
120
- .prepare(
121
- "INSERT INTO messages (session_id, seq, role, content, ts) VALUES ($sid, $seq, $role, $content, $ts)",
122
- )
123
- .run({
124
- sid: sessionId,
125
- seq: seq,
126
- role: msg.role,
127
- content: JSON.stringify(msg),
128
- ts: msg.ts,
129
- })
130
- this.#db
131
- .prepare("UPDATE sessions SET updated = $now WHERE id = $id")
132
- .run({ now: Date.now(), id: sessionId })
133
- }
134
-
135
- appendMany(sessionId: string, msgs: Msg[]): void {
136
- const tx = this.#db.transaction(() => {
137
- for (const msg of msgs) {
138
- this.append(sessionId, msg)
59
+ async list(limit = 10): Promise<Session[]> {
60
+ try {
61
+ const entries = await readdir(this.#sessionsDir, { withFileTypes: true })
62
+ const dirNames = entries
63
+ .filter((e) => e.isDirectory())
64
+ .map((e) => e.name)
65
+ .sort((a, b) => b.localeCompare(a))
66
+
67
+ const candidates = dirNames.slice(0, Math.max(limit * 2, 50))
68
+ const sessions: Session[] = []
69
+ for (const name of candidates) {
70
+ const s = await this.get(name)
71
+ if (s) sessions.push(s)
139
72
  }
140
- })
141
- tx()
73
+ sessions.sort((a, b) => b.updated - a.updated)
74
+ return sessions.slice(0, limit)
75
+ } catch {
76
+ return []
77
+ }
142
78
  }
143
79
 
144
- messages(sessionId: string): Msg[] {
145
- const rows = this.#db
146
- .prepare("SELECT content FROM messages WHERE session_id = $sid ORDER BY seq ASC")
147
- .all({ sid: sessionId }) as { content: string }[]
148
- return rows.map((r) => JSON.parse(r.content) as Msg)
80
+ async latest(): Promise<Session | null> {
81
+ const sessions = await this.list(1)
82
+ return sessions[0] ?? null
149
83
  }
150
84
 
151
- messagesAfter(sessionId: string, afterSeq: number): Msg[] {
152
- const rows = this.#db
153
- .prepare(
154
- "SELECT content FROM messages WHERE session_id = $sid AND seq > $seq ORDER BY seq ASC",
155
- )
156
- .all({ sid: sessionId, seq: afterSeq }) as { content: string }[]
157
- return rows.map((r) => JSON.parse(r.content) as Msg)
85
+ async delete(id: string): Promise<boolean> {
86
+ try {
87
+ await rm(this.#sessionDir(id), { recursive: true, force: true })
88
+ return true
89
+ } catch {
90
+ return false
91
+ }
158
92
  }
159
93
 
160
- setTitle(sessionId: string, title: string): void {
161
- this.#db
162
- .prepare("UPDATE sessions SET title = $title WHERE id = $id")
163
- .run({ title: title, id: sessionId })
94
+ async deleteAll(): Promise<void> {
95
+ try {
96
+ await rm(this.#sessionsDir, { recursive: true, force: true })
97
+ await mkdir(this.#sessionsDir, { recursive: true })
98
+ } catch {
99
+ // ignore
100
+ }
164
101
  }
165
102
 
166
- messageCount(sessionId: string): number {
167
- const row = this.#db
168
- .prepare("SELECT COUNT(*) as count FROM messages WHERE session_id = $sid")
169
- .get({ sid: sessionId }) as { count: number }
170
- return row.count
103
+ async append(sessionId: string, msg: Msg): Promise<void> {
104
+ const session = await this.get(sessionId)
105
+ if (!session) return
106
+
107
+ session.updated = Date.now()
108
+ await writeFile(this.#metadataPath(sessionId), JSON.stringify(session, null, 2))
109
+
110
+ const line = `${JSON.stringify(msg)}\n`
111
+ await appendFile(this.#messagesPath(sessionId), line)
171
112
  }
172
113
 
173
- saveCompaction(
114
+ async messages(sessionId: string): Promise<Msg[]> {
115
+ try {
116
+ const data = await readFile(this.#messagesPath(sessionId), "utf-8")
117
+ const lines = data.split("\n").filter((l) => l.trim().length > 0)
118
+ return lines.map((l) => JSON.parse(l) as Msg)
119
+ } catch {
120
+ return []
121
+ }
122
+ }
123
+
124
+ async messageCount(sessionId: string): Promise<number> {
125
+ try {
126
+ const data = await readFile(this.#messagesPath(sessionId), "utf-8")
127
+ const lines = data.split("\n").filter((l) => l.trim().length > 0)
128
+ return lines.length
129
+ } catch {
130
+ return 0
131
+ }
132
+ }
133
+
134
+ async setTitle(sessionId: string, title: string): Promise<void> {
135
+ const session = await this.get(sessionId)
136
+ if (!session) return
137
+ session.title = title
138
+ session.updated = Date.now()
139
+ await writeFile(this.#metadataPath(sessionId), JSON.stringify(session, null, 2))
140
+ }
141
+
142
+ async saveCompaction(
174
143
  sessionId: string,
175
144
  summary: string,
176
145
  filesRead: string[],
177
146
  filesWrote: string[],
178
147
  seqBefore: number,
179
- ): void {
180
- this.#db
181
- .prepare(
182
- "INSERT INTO compactions (session_id, summary, files_read, files_wrote, seq_before, ts) VALUES ($sid, $summary, $read, $wrote, $seq, $ts)",
183
- )
184
- .run({
185
- sid: sessionId,
186
- summary: summary,
187
- read: JSON.stringify(filesRead),
188
- wrote: JSON.stringify(filesWrote),
189
- seq: seqBefore,
190
- ts: Date.now(),
191
- })
192
- }
193
-
194
- getLatestCompaction(sessionId: string): { summary: string; seqBefore: number } | null {
195
- return (
196
- (this.#db
197
- .prepare(
198
- "SELECT summary, seq_before FROM compactions WHERE session_id = $sid ORDER BY ts DESC LIMIT 1",
199
- )
200
- .get({ sid: sessionId }) as { summary: string; seqBefore: number } | null) ?? null
201
- )
202
- }
203
-
204
- truncateBeforeSeq(sessionId: string, seq: number): void {
205
- this.#db
206
- .prepare("DELETE FROM messages WHERE session_id = $sid AND seq < $seq")
207
- .run({ sid: sessionId, seq: seq })
148
+ ): Promise<void> {
149
+ const compaction: Compaction = {
150
+ summary,
151
+ seqBefore,
152
+ filesRead,
153
+ filesWrote,
154
+ ts: Date.now(),
155
+ }
156
+ await writeFile(this.#compactionPath(sessionId), JSON.stringify(compaction, null, 2))
208
157
  }
209
158
 
210
- close(): void {
211
- this.#db.close()
159
+ async getLatestCompaction(sessionId: string): Promise<Compaction | null> {
160
+ try {
161
+ const data = await readFile(this.#compactionPath(sessionId), "utf-8")
162
+ return JSON.parse(data) as Compaction
163
+ } catch {
164
+ return null
165
+ }
212
166
  }
213
167
 
214
- #nextSeq(sessionId: string): number {
215
- const row = this.#db
216
- .prepare("SELECT MAX(seq) as maxSeq FROM messages WHERE session_id = $sid")
217
- .get({ sid: sessionId }) as { maxSeq: number | null }
218
- return (row.maxSeq ?? 0) + 1
168
+ async truncateBeforeSeq(sessionId: string, seq: number): Promise<void> {
169
+ const msgs = await this.messages(sessionId)
170
+ const remaining = msgs.slice(seq)
171
+ const data =
172
+ remaining.map((m) => JSON.stringify(m)).join("\n") + (remaining.length > 0 ? "\n" : "")
173
+ await writeFile(this.#messagesPath(sessionId), data)
174
+ }
175
+
176
+ async prune(limit = 10): Promise<void> {
177
+ try {
178
+ const entries = await readdir(this.#sessionsDir, { withFileTypes: true })
179
+ const dirNames = entries
180
+ .filter((e) => e.isDirectory())
181
+ .map((e) => e.name)
182
+ .sort((a, b) => b.localeCompare(a))
183
+
184
+ const targets = limit > 0 ? dirNames.slice(0, limit) : dirNames
185
+ for (const name of targets) {
186
+ const count = await this.messageCount(name)
187
+ if (count === 0) {
188
+ await this.delete(name)
189
+ }
190
+ }
191
+ } catch {
192
+ // ignore
193
+ }
194
+ }
195
+
196
+ close(): void {
197
+ // no-op
219
198
  }
220
199
  }
221
200
 
222
201
  let _store: SessionStore | null = null
223
202
 
224
- export function getSessionStore(dir?: string): SessionStore {
203
+ export async function getSessionStore(dir?: string): Promise<SessionStore> {
225
204
  if (_store) return _store
226
- const dbPath = join(dir ?? join(process.env.HOME ?? "~", ".novacode"), "sessions.db")
227
- _store = new SessionStore(dbPath)
205
+ const sessionsPath = join(dir ?? join(process.env.HOME ?? "~", ".novacode"), "sessions")
206
+ await mkdir(sessionsPath, { recursive: true })
207
+ _store = new SessionStore(sessionsPath)
228
208
  return _store
229
209
  }
package/src/tools/web.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Web tools for searching and fetching internet content.
3
- * Uses DuckDuckGo HTML for search (no API key needed) and Bun's built-in
3
+ * Uses DuckDuckGo HTML for search (no API key needed) and Node's built-in
4
4
  * fetch for reading URLs.
5
5
  */
6
6
  import type { Tool, ToolResult } from "../types.ts"
package/src/tui/app.tsx CHANGED
@@ -1,8 +1,11 @@
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"
@@ -36,23 +39,55 @@ export async function interactive(
36
39
  process.stdout.write(`${chalk.cyan.bold("⚡ novacode")} ${chalk.gray(`v${version}`)}\n`)
37
40
 
38
41
  try {
39
- 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
+ })
40
45
  await waitUntilExit()
41
46
  } finally {
42
47
  process.stdout.write("\x1B[?25h")
48
+ await store.prune()
43
49
  }
44
50
  }
45
51
 
46
52
  function App({
47
53
  agent,
48
54
  store,
49
- sessionId,
55
+ sessionId: initialSessionId,
50
56
  }: {
51
57
  agent: Agent
52
58
  store: SessionStore
53
59
  sessionId: string
54
60
  }) {
61
+ const [currSessionId, setCurrSessionId] = useState(initialSessionId)
55
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
+ )
56
91
  const [stream, setStream] = useState("")
57
92
  const [thinkStream, setThinkStream] = useState("")
58
93
  const [busy, setBusy] = useState(false)
@@ -69,6 +104,9 @@ function App({
69
104
  current: string
70
105
  latest: string
71
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)
72
110
 
73
111
  useEffect(() => {
74
112
  const check = async () => {
@@ -126,7 +164,9 @@ function App({
126
164
  function commitMsg(msg: Msg) {
127
165
  setMsgs((prev) => [...prev, msg])
128
166
  agent.setMessages([...agent.messages, msg])
129
- store.append(sessionId, msg)
167
+ store.append(currSessionId, msg).catch((err) => {
168
+ console.error("Error appending message to session store:", err)
169
+ })
130
170
  }
131
171
 
132
172
  // biome-ignore lint/correctness/useExhaustiveDependencies: reset selection on input change
@@ -135,12 +175,53 @@ function App({
135
175
  }, [input])
136
176
 
137
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
+
138
217
  if (mode.type !== "chat") return
139
218
 
140
219
  if (key.escape) {
141
220
  if (abortCtrl.current) {
142
221
  abortCtrl.current.abort()
143
222
  abortCtrl.current = null
223
+ } else if (input) {
224
+ setInput("")
144
225
  }
145
226
  return
146
227
  }
@@ -198,7 +279,7 @@ function App({
198
279
  hIdx.current = -1
199
280
 
200
281
  if (line.startsWith("/")) {
201
- dispatch(line, agent, store, sessionId, prompts).then((r) => {
282
+ dispatch(line, agent, store, currSessionId, prompts, exit, handleSwitchSession).then((r) => {
202
283
  if (r) {
203
284
  commitMsg({
204
285
  role: "assistant",
@@ -258,6 +339,20 @@ function App({
258
339
  break
259
340
  case "turn_end":
260
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(() => {})
261
356
  break
262
357
  case "usage":
263
358
  if (ev.usage) setUsage(ev.usage)
@@ -311,13 +406,7 @@ function App({
311
406
  {(m, i) => <Message key={`${m.ts}-${i}`} msg={m} isFirst={i === 0} />}
312
407
  </Static>
313
408
 
314
- <LiveArea
315
- stream={stream}
316
- thinkStream={thinkStream}
317
- busy={busy}
318
- status={status}
319
- hasMessages={visibleMsgs.length > 0}
320
- />
409
+ <LiveArea stream={stream} thinkStream={thinkStream} busy={busy} status={status} />
321
410
 
322
411
  <Box flexDirection="column" marginTop={visibleMsgs.length > 0 || isLiveActive ? 1 : 0}>
323
412
  {updateInfo && (
@@ -357,6 +446,7 @@ function App({
357
446
  busy={busy}
358
447
  suggestions={suggestions}
359
448
  selCmdIdx={selCmdIdx}
449
+ exitConfirmKey={exitConfirmKey}
360
450
  />
361
451
  </Box>
362
452
  </Box>
@@ -1,10 +1,9 @@
1
1
  import chalk from "chalk"
2
2
  import { Box, Text } from "ink"
3
3
  import { useEffect, useState } from "react"
4
+ import { SPINNER_FRAMES } from "../constants.ts"
4
5
  import { formatMarkdown } from "../markdown.ts"
5
6
 
6
- const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
7
-
8
7
  export function Spinner() {
9
8
  const [frame, setFrame] = useState(0)
10
9
 
@@ -32,19 +31,17 @@ export function LiveArea({
32
31
  thinkStream,
33
32
  busy,
34
33
  status,
35
- hasMessages,
36
34
  }: {
37
35
  stream: string
38
36
  thinkStream: string
39
37
  busy: boolean
40
38
  status: string
41
- hasMessages: boolean
42
39
  }) {
43
40
  const isActive = !!(stream || thinkStream || busy)
44
41
  if (!isActive) return null
45
42
 
46
43
  return (
47
- <Box flexDirection="column" marginTop={hasMessages ? 1 : 0}>
44
+ <Box flexDirection="column" marginTop={0}>
48
45
  {thinkStream && (
49
46
  <Text dimColor italic>
50
47
  {thinkStream}
@@ -1,24 +1,15 @@
1
1
  import { Box, Text } from "ink"
2
2
  import type { Msg } from "../../types.ts"
3
3
  import { formatToolArgs } from "../../util.ts"
4
+ import { TERMINATION_PHRASES, TOOL_STYLE } from "../constants.ts"
4
5
  import { formatMarkdown } from "../markdown.ts"
5
6
 
6
- const TOOL_STYLE: Record<string, string> = {
7
- read: "blue",
8
- write: "magenta",
9
- edit: "yellow",
10
- bash: "cyan",
11
- glob: "green",
12
- find: "green",
13
- grep: "green",
14
- tree: "green",
15
- }
16
-
17
7
  export function hasMeaningfulContent(msg: Msg): boolean {
18
8
  if (msg.role === "user") return true
19
9
  if (msg.role === "tool_result") return true
20
10
  if (msg.role === "assistant") {
21
11
  if (msg.model === "system") return true
12
+ if (msg.stop === "aborted") return true
22
13
  return msg.content.some((c) => {
23
14
  if (c.type === "thinking") return c.text.trim().length > 0
24
15
  if (c.type === "text") return c.text.trim().length > 0
@@ -60,9 +51,15 @@ export function Message({ msg, isFirst }: { msg: Msg; isFirst: boolean }) {
60
51
  )
61
52
  }
62
53
 
63
- const hasVisibleContent = msg.content.some((c) => c.type === "text" || c.type === "thinking")
54
+ const isAborted = msg.stop === "aborted"
55
+ const hasVisibleContent =
56
+ isAborted || msg.content.some((c) => c.type === "text" || c.type === "thinking")
64
57
  if (!hasVisibleContent) return null
65
58
 
59
+ const termPhrase = isAborted
60
+ ? (TERMINATION_PHRASES[msg.ts % TERMINATION_PHRASES.length] ?? "Terminated by user")
61
+ : ""
62
+
66
63
  return (
67
64
  <Box flexDirection="column" marginTop={0}>
68
65
  {msg.content.map((c, i) => {
@@ -80,6 +77,13 @@ export function Message({ msg, isFirst }: { msg: Msg; isFirst: boolean }) {
80
77
  }
81
78
  return null
82
79
  })}
80
+ {isAborted && (
81
+ <Box marginTop={0}>
82
+ <Text color="red" italic>
83
+ ▲ {termPhrase}
84
+ </Text>
85
+ </Box>
86
+ )}
83
87
  </Box>
84
88
  )
85
89
  }