novacode 0.5.2 → 0.5.5

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.
@@ -0,0 +1,113 @@
1
+ import { Box, Text } from "ink"
2
+ import type { Msg } from "../../types.ts"
3
+ import { formatToolArgs } from "../../util.ts"
4
+ import { formatMarkdown } from "../markdown.ts"
5
+
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
+ export function hasMeaningfulContent(msg: Msg): boolean {
18
+ if (msg.role === "user") return true
19
+ if (msg.role === "tool_result") return true
20
+ if (msg.role === "assistant") {
21
+ if (msg.model === "system") return true
22
+ return msg.content.some((c) => {
23
+ if (c.type === "thinking") return c.text.trim().length > 0
24
+ if (c.type === "text") return c.text.trim().length > 0
25
+ return false
26
+ })
27
+ }
28
+ return false
29
+ }
30
+
31
+ export function Message({ msg, isFirst }: { msg: Msg; isFirst: boolean }) {
32
+ if (msg.role === "user") {
33
+ return (
34
+ <Box marginTop={isFirst ? 0 : 1} flexDirection="row">
35
+ <Box flexShrink={0} marginRight={1}>
36
+ <Text bold color="green">
37
+ {">"}
38
+ </Text>
39
+ </Box>
40
+ <Box flexGrow={1} flexShrink={1}>
41
+ <Text>
42
+ {typeof msg.content === "string"
43
+ ? msg.content
44
+ : msg.content.map((c) => (c.type === "text" ? c.text : "")).join("")}
45
+ </Text>
46
+ </Box>
47
+ </Box>
48
+ )
49
+ }
50
+
51
+ if (msg.role === "assistant") {
52
+ if (msg.model === "system") {
53
+ return (
54
+ <Box flexDirection="column" marginTop={0}>
55
+ {msg.content.map((c, i) =>
56
+ // biome-ignore lint/suspicious/noArrayIndexKey: stable turn content
57
+ c.type === "text" ? <Text key={i}>{formatMarkdown(c.text)}</Text> : null,
58
+ )}
59
+ </Box>
60
+ )
61
+ }
62
+
63
+ const hasVisibleContent = msg.content.some((c) => c.type === "text" || c.type === "thinking")
64
+ if (!hasVisibleContent) return null
65
+
66
+ return (
67
+ <Box flexDirection="column" marginTop={0}>
68
+ {msg.content.map((c, i) => {
69
+ if (c.type === "thinking") {
70
+ return (
71
+ // biome-ignore lint/suspicious/noArrayIndexKey: stable turn content
72
+ <Text key={i} dimColor italic>
73
+ {c.text}
74
+ </Text>
75
+ )
76
+ }
77
+ if (c.type === "text") {
78
+ // biome-ignore lint/suspicious/noArrayIndexKey: stable turn content
79
+ return <Text key={i}>{formatMarkdown(c.text)}</Text>
80
+ }
81
+ return null
82
+ })}
83
+ </Box>
84
+ )
85
+ }
86
+
87
+ if (msg.role === "tool_result") {
88
+ const args = msg.args ? formatToolArgs(msg.args, true) : ""
89
+
90
+ const resText = msg.content
91
+ .map((c) => (c.type === "text" ? c.text : ""))
92
+ .join("")
93
+ .trim()
94
+
95
+ const isRead = msg.tool === "read"
96
+ const lineCount = isRead ? resText.split("\n").length : 0
97
+ const color = TOOL_STYLE[msg.tool] || "white"
98
+
99
+ return (
100
+ <Box flexDirection="row">
101
+ <Text color={msg.isError ? "red" : "green"}>{msg.isError ? "✗" : "✓"} </Text>
102
+ <Text color={color} bold>
103
+ {msg.tool}
104
+ </Text>
105
+ {args && <Text> {args}</Text>}
106
+ {isRead && !msg.isError && <Text dimColor> ({lineCount} lines)</Text>}
107
+ {msg.isError && resText && <Text color="red"> {resText.slice(0, 80)}</Text>}
108
+ </Box>
109
+ )
110
+ }
111
+
112
+ return null
113
+ }
@@ -0,0 +1,58 @@
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
+ }: {
22
+ model: Model
23
+ usage: { in: number; out: number }
24
+ busy: boolean
25
+ suggestions: Array<{ name: string; desc: string }>
26
+ selCmdIdx: number
27
+ }) {
28
+ return (
29
+ <Box justifyContent="space-between">
30
+ <Box>
31
+ {suggestions.length > 0 ? (
32
+ <Box flexDirection="column" marginLeft={2}>
33
+ {suggestions.map((s, i) => (
34
+ <Box key={s.name}>
35
+ <Text
36
+ color={i === selCmdIdx ? "black" : "yellow"}
37
+ backgroundColor={i === selCmdIdx ? "yellow" : undefined}
38
+ >
39
+ /{s.name.padEnd(10)}
40
+ </Text>
41
+ <Text dimColor> {s.desc}</Text>
42
+ </Box>
43
+ ))}
44
+ </Box>
45
+ ) : (
46
+ <Text dimColor>Enter to send · /help for commands</Text>
47
+ )}
48
+ </Box>
49
+
50
+ <Box>
51
+ <Text dimColor>{formatTokenUsage(usage.in, model.contextWindow)}</Text>
52
+ <Text dimColor> │ </Text>
53
+ <Text dimColor>{model.id}</Text>
54
+ {busy && <Text dimColor> │ Esc to stop</Text>}
55
+ </Box>
56
+ </Box>
57
+ )
58
+ }
@@ -0,0 +1,205 @@
1
+ import { Box, render, Text, useInput } from "ink"
2
+ import { useState } from "react"
3
+
4
+ interface SelectOption {
5
+ value: string
6
+ label: string
7
+ hint?: string
8
+ }
9
+
10
+ export function SelectPrompt({
11
+ message,
12
+ options,
13
+ header,
14
+ onSelect,
15
+ }: {
16
+ message: string
17
+ options: SelectOption[]
18
+ header?: string
19
+ onSelect: (value: string | null) => void
20
+ }) {
21
+ const [idx, setIdx] = useState(0)
22
+
23
+ useInput((_, key) => {
24
+ if (key.escape) {
25
+ onSelect(null)
26
+ return
27
+ }
28
+ if (key.upArrow) {
29
+ setIdx((i) => (i - 1 + options.length) % options.length)
30
+ return
31
+ }
32
+ if (key.downArrow) {
33
+ setIdx((i) => (i + 1) % options.length)
34
+ return
35
+ }
36
+ if (key.return) {
37
+ onSelect(options[idx]?.value ?? null)
38
+ }
39
+ })
40
+
41
+ return (
42
+ <Box flexDirection="column" paddingX={1}>
43
+ {header && (
44
+ <Box marginBottom={1}>
45
+ <Text>{header}</Text>
46
+ </Box>
47
+ )}
48
+ <Box marginBottom={1}>
49
+ <Text bold>{message}</Text>
50
+ </Box>
51
+ {options.map((opt, i) => (
52
+ <Box key={opt.value}>
53
+ <Text color={i === idx ? "green" : undefined}>
54
+ {i === idx ? "❯ " : " "}
55
+ {opt.label}
56
+ </Text>
57
+ {opt.hint && i === idx && <Text dimColor> {opt.hint}</Text>}
58
+ </Box>
59
+ ))}
60
+ <Box marginTop={1}>
61
+ <Text dimColor>↑↓ navigate · Enter select · Esc cancel</Text>
62
+ </Box>
63
+ </Box>
64
+ )
65
+ }
66
+
67
+ export function PasswordPrompt({
68
+ message,
69
+ validate,
70
+ onSubmit,
71
+ }: {
72
+ message: string
73
+ validate?: (v: string) => string | undefined
74
+ onSubmit: (value: string | null) => void
75
+ }) {
76
+ const [value, setValue] = useState("")
77
+ const [error, setError] = useState("")
78
+
79
+ useInput((ch, key) => {
80
+ if (key.escape) {
81
+ onSubmit(null)
82
+ return
83
+ }
84
+ if (key.return) {
85
+ const err = validate?.(value)
86
+ if (err) {
87
+ setError(err)
88
+ return
89
+ }
90
+ onSubmit(value)
91
+ return
92
+ }
93
+ if (key.backspace || key.delete) {
94
+ setValue((v) => v.slice(0, -1))
95
+ setError("")
96
+ return
97
+ }
98
+ if (ch) {
99
+ setValue((v) => v + ch)
100
+ setError("")
101
+ }
102
+ })
103
+
104
+ return (
105
+ <Box flexDirection="column" paddingX={1}>
106
+ <Box marginBottom={1}>
107
+ <Text bold>{message}</Text>
108
+ </Box>
109
+ <Box>
110
+ <Text color="green">│ </Text>
111
+ <Text dimColor>{"*".repeat(value.length)}</Text>
112
+ <Text color="green">│</Text>
113
+ </Box>
114
+ {error && (
115
+ <Box>
116
+ <Text color="red">{error}</Text>
117
+ </Box>
118
+ )}
119
+ <Box marginTop={1}>
120
+ <Text dimColor>Enter submit · Esc cancel</Text>
121
+ </Box>
122
+ </Box>
123
+ )
124
+ }
125
+
126
+ export function ConfirmPrompt({
127
+ message,
128
+ onConfirm,
129
+ }: {
130
+ message: string
131
+ onConfirm: (value: boolean | null) => void
132
+ }) {
133
+ const [yes, setYes] = useState(true)
134
+
135
+ useInput((_, key) => {
136
+ if (key.escape) {
137
+ onConfirm(null)
138
+ return
139
+ }
140
+ if (key.leftArrow || key.rightArrow || key.tab) {
141
+ setYes((y) => !y)
142
+ return
143
+ }
144
+ if (key.return) {
145
+ onConfirm(yes)
146
+ }
147
+ })
148
+
149
+ return (
150
+ <Box flexDirection="column" paddingX={1}>
151
+ <Box marginBottom={1}>
152
+ <Text bold>{message}</Text>
153
+ </Box>
154
+ <Box>
155
+ <Text color={yes ? "green" : undefined}>{yes ? "❯ " : " "}Yes</Text>
156
+ </Box>
157
+ <Box>
158
+ <Text color={!yes ? "red" : undefined}>{!yes ? "❯ " : " "}No</Text>
159
+ </Box>
160
+ <Box marginTop={1}>
161
+ <Text dimColor>←→ toggle · Enter confirm · Esc cancel</Text>
162
+ </Box>
163
+ </Box>
164
+ )
165
+ }
166
+
167
+ // Standalone wrappers for use outside the main TUI (e.g. onboarding)
168
+
169
+ export function standaloneSelect(
170
+ message: string,
171
+ options: SelectOption[],
172
+ header?: string,
173
+ ): Promise<string | null> {
174
+ return new Promise((resolve) => {
175
+ const { unmount } = render(
176
+ <SelectPrompt
177
+ message={message}
178
+ options={options}
179
+ header={header}
180
+ onSelect={(v) => {
181
+ unmount()
182
+ resolve(v)
183
+ }}
184
+ />,
185
+ )
186
+ })
187
+ }
188
+
189
+ export function standalonePassword(
190
+ message: string,
191
+ validate?: (v: string) => string | undefined,
192
+ ): Promise<string | null> {
193
+ return new Promise((resolve) => {
194
+ const { unmount } = render(
195
+ <PasswordPrompt
196
+ message={message}
197
+ validate={validate}
198
+ onSubmit={(v) => {
199
+ unmount()
200
+ resolve(v)
201
+ }}
202
+ />,
203
+ )
204
+ })
205
+ }
package/src/types.ts CHANGED
@@ -224,6 +224,21 @@ export interface AssistantResult {
224
224
  stop: StopReason
225
225
  }
226
226
 
227
+ /** Prompts — used by interactive commands within the TUI */
228
+
229
+ export interface Prompts {
230
+ select(config: {
231
+ message: string
232
+ header?: string
233
+ options: Array<{ value: string; label: string; hint?: string }>
234
+ }): Promise<string | null>
235
+ password(config: {
236
+ message: string
237
+ validate?: (v: string) => string | undefined
238
+ }): Promise<string | null>
239
+ confirm(config: { message: string }): Promise<boolean | null>
240
+ }
241
+
227
242
  /** Commands */
228
243
 
229
244
  export interface Cmd {
package/src/update.ts CHANGED
@@ -1,5 +1,10 @@
1
- import { join } from "node:path"
2
- import { semver } from "bun"
1
+ import { spawn } from "node:child_process"
2
+ import { readFile } from "node:fs/promises"
3
+ import { dirname, join } from "node:path"
4
+ import { fileURLToPath } from "node:url"
5
+ import semver from "semver"
6
+
7
+ const __dirname = dirname(fileURLToPath(import.meta.url))
3
8
 
4
9
  let cachedLatest: string | null = null
5
10
  let cachedCurrent: string | null = null
@@ -7,7 +12,8 @@ let cachedCurrent: string | null = null
7
12
  export async function getCurrentVersion(): Promise<string> {
8
13
  if (cachedCurrent) return cachedCurrent
9
14
  try {
10
- const pkg = await Bun.file(join(import.meta.dir, "..", "package.json")).json()
15
+ const raw = await readFile(join(__dirname, "..", "package.json"), "utf-8")
16
+ const pkg = JSON.parse(raw)
11
17
  cachedCurrent = (pkg.version as string) ?? "0.0.0"
12
18
  return cachedCurrent
13
19
  } catch {
@@ -18,15 +24,20 @@ export async function getCurrentVersion(): Promise<string> {
18
24
  export async function getLatestVersion(): Promise<string | null> {
19
25
  if (cachedLatest) return cachedLatest
20
26
  try {
21
- const proc = Bun.spawn(["bun", "info", "novacode", "version"], {
22
- stdout: "pipe",
23
- stderr: "ignore",
27
+ const proc = spawn("npm", ["info", "novacode", "version"], {
28
+ stdio: ["ignore", "pipe", "ignore"],
29
+ })
30
+ const text = await new Promise<string>((resolve, reject) => {
31
+ let out = ""
32
+ proc.stdout.on("data", (chunk: Buffer) => {
33
+ out += chunk.toString()
34
+ })
35
+ proc.on("error", reject)
36
+ proc.on("close", () => resolve(out.trim()))
24
37
  })
25
- const text = await new Response(proc.stdout).text()
26
- const latest = text.trim()
27
- if (latest) {
28
- cachedLatest = latest
29
- return latest
38
+ if (text) {
39
+ cachedLatest = text
40
+ return text
30
41
  }
31
42
  } catch {}
32
43
  return null
@@ -41,26 +52,36 @@ export async function checkForUpdate(): Promise<{
41
52
  const latest = await getLatestVersion()
42
53
  if (!latest) return null
43
54
  return {
44
- hasUpdate: semver.order(latest, current) === 1,
55
+ hasUpdate: semver.gt(latest, current),
45
56
  current,
46
57
  latest,
47
58
  }
48
59
  }
49
60
 
50
61
  export async function runUpdate(silent = false): Promise<boolean> {
51
- const proc = Bun.spawn(["bun", "update", "-g", "novacode", "--latest"], {
52
- stdout: silent ? "ignore" : "inherit",
53
- stderr: silent ? "ignore" : "inherit",
54
- })
55
- const exitCode = await proc.exited
56
- if (exitCode === 0) {
57
- if (!silent) {
58
- console.log("✓ novacode updated to latest version successfully.")
62
+ try {
63
+ const proc = spawn("npm", ["update", "-g", "novacode"], {
64
+ stdio: silent ? "ignore" : "inherit",
65
+ })
66
+ const exitCode = await new Promise<number>((resolve, reject) => {
67
+ proc.on("error", reject)
68
+ proc.on("close", (code) => resolve(code ?? -1))
69
+ })
70
+ if (exitCode === 0) {
71
+ if (!silent) {
72
+ console.log("✓ novacode updated to latest version successfully.")
73
+ }
74
+ return true
75
+ } else {
76
+ if (!silent) {
77
+ console.error(`Update failed (exit code ${exitCode})`)
78
+ process.exit(1)
79
+ }
80
+ return false
59
81
  }
60
- return true
61
- } else {
82
+ } catch (e) {
62
83
  if (!silent) {
63
- console.error(`Update failed (exit code ${exitCode})`)
84
+ console.error(`Update failed: ${(e as Error).message}`)
64
85
  process.exit(1)
65
86
  }
66
87
  return false
package/src/util.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { isAbsolute, relative } from "node:path"
2
2
  import chalk from "chalk"
3
- import type { ContentPart, Msg, TextPart } from "./types.ts"
3
+ import type { Msg, TextPart } from "./types.ts"
4
4
 
5
5
  // ~4 chars per token for English/code. Close enough for capacity warnings.
6
6
  export function estimateTokens(messages: Msg[]): number {
@@ -21,33 +21,6 @@ export function textPart(s: string): TextPart {
21
21
  return { type: "text", text: s }
22
22
  }
23
23
 
24
- export function consolidate(parts: ContentPart[]): ContentPart[] {
25
- if (parts.length === 0) return parts
26
- const out: ContentPart[] = []
27
- for (const p of parts) {
28
- const last = out[out.length - 1]
29
- if (last?.type === "text" && p.type === "text") {
30
- last.text += p.text
31
- } else if (last?.type === "thinking" && p.type === "thinking") {
32
- last.text += p.text
33
- } else {
34
- out.push({ ...p })
35
- }
36
- }
37
-
38
- const hasTool = out.some((p) => p.type === "tool_call")
39
- if (hasTool) {
40
- return out.filter((p) => {
41
- if (p.type === "text") {
42
- return p.text.trim().length > 0
43
- }
44
- return true
45
- })
46
- }
47
-
48
- return out
49
- }
50
-
51
24
  export function getRelativeIfInside(cwd: string, filePath: string): string {
52
25
  if (filePath === cwd || filePath.startsWith(`${cwd}/`)) {
53
26
  return relative(cwd, filePath) || "."
@@ -1,62 +0,0 @@
1
- /**
2
- * Registry for different model providers (OpenAI, Anthropic, etc.).
3
- * Provides a unified streaming interface for the agent loop.
4
- */
5
- import type { AgentEvent, ApiFormat, AssistantResult, StreamFn, StreamOpts } from "../types.ts"
6
- import { EventStream } from "./stream.ts"
7
-
8
- export type { AssistantResult, StreamEvent, StreamFn, StreamOpts } from "../types.ts"
9
-
10
- // Internal map of registered provider implementations
11
- const registry = new Map<ApiFormat, StreamFn>()
12
-
13
- export function register(api: ApiFormat, fn: StreamFn): void {
14
- registry.set(api, fn)
15
- }
16
-
17
- // Bridges provider-specific StreamEvents into AgentEvents so the loop and TUI deal with one type.
18
- export function stream(opts: StreamOpts): EventStream<AgentEvent, AssistantResult> {
19
- const fn = registry.get(opts.api)
20
- if (!fn) throw new Error(`No provider registered for API format: ${opts.api}`)
21
-
22
- // Bridge layer: converts provider-specific StreamEvents into the agent's
23
- // AgentEvent shape, so the loop and TUI only deal with one event type.
24
- const providerStream = fn(opts)
25
- const agentStream = new EventStream<AgentEvent, AssistantResult>()
26
-
27
- ;(async () => {
28
- for await (const event of providerStream) {
29
- if (event.type === "text_delta") {
30
- agentStream.push({ type: "text_delta", text: event.text ?? "" })
31
- } else if (event.type === "thinking_delta") {
32
- agentStream.push({ type: "thinking_delta", text: event.text ?? "" })
33
- } else if (event.type === "tool_call" && event.call) {
34
- agentStream.push({
35
- type: "tool_call",
36
- call: {
37
- type: "tool_call",
38
- id: event.call.id,
39
- name: event.call.name,
40
- args: event.call.args,
41
- },
42
- })
43
- } else if (event.type === "usage" && event.usage) {
44
- agentStream.push({ type: "usage", usage: event.usage })
45
- }
46
- }
47
-
48
- const res = providerStream.result
49
- if (res) {
50
- agentStream.finish(res)
51
- } else {
52
- // Fallback for unexpected closure
53
- agentStream.finish({ content: [], usage: { in: 0, out: 0 }, stop: "stop" })
54
- }
55
- })()
56
-
57
- return agentStream
58
- }
59
-
60
- export function getRegisteredApis(): ApiFormat[] {
61
- return [...registry.keys()]
62
- }