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.
- package/README.md +16 -10
- package/dist/app-bQ9a_p_K.mjs +22 -0
- package/dist/app-bQ9a_p_K.mjs.map +1 -0
- package/dist/main.mjs +33 -56
- package/dist/main.mjs.map +1 -1
- package/package.json +3 -4
- package/src/commands/compact.ts +1 -1
- package/src/commands/index.ts +46 -4
- package/src/commands/session.ts +23 -11
- package/src/main.ts +57 -27
- package/src/provider/gemini.ts +11 -3
- package/src/provider/openai.ts +28 -4
- package/src/session/compact.ts +43 -10
- package/src/session/store.ts +165 -185
- package/src/tools/web.ts +1 -1
- package/src/tui/app.tsx +102 -12
- package/src/tui/components/liveArea.tsx +2 -5
- package/src/tui/components/message.tsx +16 -12
- package/src/tui/components/statusBar.tsx +6 -0
- package/src/tui/constants.ts +25 -0
- package/src/types.ts +14 -0
- package/src/util.ts +19 -0
- package/dist/app-QfQR2FN9.mjs +0 -21
- package/dist/app-QfQR2FN9.mjs.map +0 -1
package/src/session/store.ts
CHANGED
|
@@ -1,229 +1,209 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { appendFile, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"
|
|
2
2
|
import { join } from "node:path"
|
|
3
|
-
import
|
|
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
|
-
#
|
|
10
|
+
#sessionsDir: string
|
|
45
11
|
|
|
46
|
-
constructor(
|
|
47
|
-
this.#
|
|
12
|
+
constructor(sessionsDir: string) {
|
|
13
|
+
this.#sessionsDir = sessionsDir
|
|
48
14
|
}
|
|
49
15
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
52
|
+
const data = await readFile(this.#metadataPath(id), "utf-8")
|
|
53
|
+
return JSON.parse(data) as Session
|
|
61
54
|
} catch {
|
|
62
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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
|
-
|
|
145
|
-
const
|
|
146
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
211
|
-
|
|
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
|
-
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
|
227
|
-
|
|
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
|
|
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(
|
|
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,
|
|
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={
|
|
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
|
|
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
|
}
|