@onyx-robotics/agent 0.1.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/lib/tui.ts ADDED
@@ -0,0 +1,364 @@
1
+ // Pure rendering helpers shared by `onyx exp list` and `onyx listen`.
2
+ // Everything is string-in/string-out (no I/O, no terminal state) so layout
3
+ // stays unit-testable; commands/listen.ts owns the actual terminal lifecycle.
4
+ // Column set and status vocabulary mirror the web app's tree view
5
+ // (apps/web/components/views/tree-view.tsx) so both surfaces read the same.
6
+
7
+ import type { LocalResearchHistoryRecord } from "../protocol"
8
+
9
+ const CSI = "\x1b["
10
+
11
+ function paint(code: string, text: string, color: boolean) {
12
+ return color ? `${CSI}${code}m${text}${CSI}0m` : text
13
+ }
14
+
15
+ export const dim = (text: string, color: boolean) => paint("2", text, color)
16
+ export const bold = (text: string, color: boolean) => paint("1", text, color)
17
+ export const green = (text: string, color: boolean) => paint("32", text, color)
18
+ export const red = (text: string, color: boolean) => paint("31", text, color)
19
+ export const yellow = (text: string, color: boolean) => paint("33", text, color)
20
+ export const cyan = (text: string, color: boolean) => paint("36", text, color)
21
+
22
+ /** Strips ANSI color/style sequences (for length math and tests). */
23
+ export function stripAnsi(text: string) {
24
+ // eslint-disable-next-line no-control-regex -- matching ANSI escapes is the point
25
+ return text.replaceAll(/\x1b\[\d+m/g, "")
26
+ }
27
+
28
+ /** Truncates by visible length, passing ANSI sequences through untouched. */
29
+ export function truncateAnsi(line: string, width: number) {
30
+ if (stripAnsi(line).length <= width) return line
31
+ let visible = 0
32
+ let out = ""
33
+ let i = 0
34
+ while (i < line.length && visible < width - 1) {
35
+ if (line[i] === "\x1b") {
36
+ const end = line.indexOf("m", i)
37
+ if (end === -1) break
38
+ out += line.slice(i, end + 1)
39
+ i = end + 1
40
+ continue
41
+ }
42
+ out += line[i]
43
+ visible += 1
44
+ i += 1
45
+ }
46
+ return `${out}…${CSI}0m`
47
+ }
48
+
49
+ /** Truncates to width, appending an ellipsis when text was cut. */
50
+ export function truncate(text: string, width: number) {
51
+ if (width <= 0) return ""
52
+ if (text.length <= width) return text
53
+ if (width === 1) return "…"
54
+ return `${text.slice(0, width - 1)}…`
55
+ }
56
+
57
+ /** Pads (and truncates) to an exact width. */
58
+ export function pad(
59
+ text: string,
60
+ width: number,
61
+ align: "left" | "right" = "left"
62
+ ) {
63
+ const cut = truncate(text, width)
64
+ const fill = " ".repeat(width - cut.length)
65
+ return align === "left" ? `${cut}${fill}` : `${fill}${cut}`
66
+ }
67
+
68
+ /** Compact relative age: now, 42s, 5m, 2h, 3d. */
69
+ export function formatAge(iso: string | null | undefined, nowMs: number) {
70
+ if (!iso) return "—"
71
+ const then = Date.parse(iso)
72
+ if (!Number.isFinite(then)) return "—"
73
+ const seconds = Math.max(0, Math.floor((nowMs - then) / 1000))
74
+ if (seconds < 5) return "now"
75
+ if (seconds < 60) return `${seconds}s`
76
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m`
77
+ if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`
78
+ return `${Math.floor(seconds / 86400)}d`
79
+ }
80
+
81
+ export function formatMetricValue(value: number | null | undefined) {
82
+ if (value === null || value === undefined) return "—"
83
+ return value.toLocaleString(undefined, { maximumFractionDigits: 4 })
84
+ }
85
+
86
+ /** `name=value`, truncating the name (never the value) to fit the width. */
87
+ export function formatMetricCell(
88
+ name: string,
89
+ value: number | null | undefined,
90
+ width: number
91
+ ) {
92
+ if (value === null || value === undefined) return "—"
93
+ const formatted = formatMetricValue(value)
94
+ const full = `${name}=${formatted}`
95
+ if (full.length <= width) return full
96
+ return `${truncate(name, Math.max(1, width - formatted.length - 1))}=${formatted}`
97
+ }
98
+
99
+ const pad2 = (n: number) => String(n).padStart(2, "0")
100
+
101
+ /** `MM-DD HH:mm:ss` — the web tree view's created column, plus seconds. */
102
+ export function formatDateTime(iso: string | null | undefined) {
103
+ if (!iso) return "—"
104
+ const d = new Date(iso)
105
+ if (Number.isNaN(d.getTime())) return "—"
106
+ return `${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`
107
+ }
108
+
109
+ /** Duration formatting matching the web tree view. */
110
+ export function formatDuration(
111
+ startedAt: string | null | undefined,
112
+ completedAt: string | null | undefined,
113
+ nowMs: number
114
+ ) {
115
+ if (!startedAt) return "—"
116
+ const start = Date.parse(startedAt)
117
+ if (!Number.isFinite(start)) return "—"
118
+ const end = completedAt ? Date.parse(completedAt) : nowMs
119
+ const totalSec = Math.max(0, (end - start) / 1000)
120
+ if (totalSec < 60) return `${totalSec.toFixed(4)}s`
121
+ const totalMin = Math.floor(totalSec / 60)
122
+ const s = (totalSec - totalMin * 60).toFixed(4)
123
+ if (totalMin < 60) return `${totalMin}m ${s}s`
124
+ const h = Math.floor(totalMin / 60)
125
+ const remM = totalMin % 60
126
+ return `${h}h ${remM}m ${s}s`
127
+ }
128
+
129
+ // Status glyphs and labels mirror glyphFor() in the web tree view.
130
+ type StatusGlyph = {
131
+ char: string
132
+ label: string
133
+ colorize: (text: string, color: boolean) => string
134
+ }
135
+
136
+ export function glyphFor(status: string): StatusGlyph {
137
+ if (status === "running")
138
+ return { char: "◦", label: "running", colorize: dim }
139
+ if (status === "queued") return { char: "·", label: "queued", colorize: dim }
140
+ if (status === "failed") return { char: "✗", label: "failed", colorize: red }
141
+ if (status === "checks_failed")
142
+ return { char: "!", label: "checks", colorize: yellow }
143
+ if (status === "rejected")
144
+ return { char: "!", label: "rejected", colorize: yellow }
145
+ return { char: "•", label: "ok", colorize: green }
146
+ }
147
+
148
+ export type ExperimentRow = Pick<
149
+ LocalResearchHistoryRecord,
150
+ | "branchName"
151
+ | "name"
152
+ | "status"
153
+ | "primaryMetricName"
154
+ | "primaryMetricValue"
155
+ | "createdAt"
156
+ > & {
157
+ sequenceNumber?: number
158
+ source?: "local" | "api"
159
+ description?: string | null
160
+ startedAt?: string | null
161
+ completedAt?: string | null
162
+ }
163
+
164
+ const GLYPH_W = 1
165
+ const SEQ_W = 4
166
+ const BRANCH_W = 16
167
+ const METRIC_W = 10
168
+ const CREATED_W = 14
169
+ const DURATION_W = 14
170
+ const STATUS_W = 8
171
+ // Extra breathing room between the metric and created columns.
172
+ const METRIC_GAP = " "
173
+
174
+ /**
175
+ * Renders the tree-view experiment table as aligned plain lines:
176
+ * glyph · #seq · name · description · metric · created · duration · status.
177
+ * Description and duration columns drop out on narrow widths. Cells are
178
+ * padded before color is applied so ANSI codes never skew alignment.
179
+ */
180
+ export function renderExperimentTable(
181
+ rows: ExperimentRow[],
182
+ options: {
183
+ columns: number
184
+ color: boolean
185
+ nowMs: number
186
+ showBranch?: boolean
187
+ }
188
+ ): string[] {
189
+ const { columns, color, nowMs } = options
190
+ const showBranch = options.showBranch ?? false
191
+ const showDescription = columns >= 90
192
+ const showDuration = columns >= 110
193
+
194
+ let fixed =
195
+ GLYPH_W + SEQ_W + METRIC_W + METRIC_GAP.length + CREATED_W + STATUS_W + 5 // gaps for base cols
196
+ if (showBranch) fixed += BRANCH_W + 1
197
+ if (showDuration) fixed += DURATION_W + 1
198
+ const flex = Math.max(showDescription ? 24 : 12, columns - fixed)
199
+ const nameWidth = showDescription ? Math.ceil(flex / 2) : flex
200
+ const descWidth = showDescription ? flex - nameWidth - 1 : 0
201
+
202
+ const cells = (parts: (string | null)[]) =>
203
+ parts.filter((part): part is string => part !== null).join(" ")
204
+
205
+ const header = cells([
206
+ " ".repeat(GLYPH_W),
207
+ pad("#", SEQ_W, "right"),
208
+ showBranch ? pad("BRANCH", BRANCH_W) : null,
209
+ pad("NAME", nameWidth),
210
+ showDescription ? pad("DESCRIPTION", descWidth) : null,
211
+ `${pad("METRIC", METRIC_W, "right")}${METRIC_GAP}`,
212
+ pad("CREATED", CREATED_W, "right"),
213
+ showDuration ? pad("DURATION", DURATION_W, "right") : null,
214
+ pad("STATUS", STATUS_W, "right"),
215
+ ])
216
+
217
+ const lines = [dim(truncate(header, columns), color)]
218
+ for (const row of rows) {
219
+ const glyph = glyphFor(row.status)
220
+ const seq =
221
+ row.sequenceNumber !== undefined ? `#${row.sequenceNumber}` : "·"
222
+ const statusPadded = pad(glyph.label, STATUS_W, "right")
223
+ lines.push(
224
+ truncateAnsi(
225
+ cells([
226
+ glyph.colorize(glyph.char, color),
227
+ dim(pad(seq, SEQ_W, "right"), color),
228
+ showBranch ? pad(row.branchName, BRANCH_W) : null,
229
+ pad(row.name, nameWidth),
230
+ showDescription
231
+ ? dim(pad(row.description?.trim() || "—", descWidth), color)
232
+ : null,
233
+ `${pad(formatMetricValue(row.primaryMetricValue), METRIC_W, "right")}${METRIC_GAP}`,
234
+ dim(pad(formatDateTime(row.createdAt), CREATED_W, "right"), color),
235
+ showDuration
236
+ ? dim(
237
+ pad(
238
+ formatDuration(row.startedAt, row.completedAt, nowMs),
239
+ DURATION_W,
240
+ "right"
241
+ ),
242
+ color
243
+ )
244
+ : null,
245
+ statusPadded.replace(glyph.label, glyph.colorize(glyph.label, color)),
246
+ ]),
247
+ columns
248
+ )
249
+ )
250
+ }
251
+ return lines
252
+ }
253
+
254
+ // Braille square (⣿) with one border dot removed per frame; the gap orbits
255
+ // the square clockwise (top-left → top-right → down the right side → bottom
256
+ // → up the left side). A terminal cell renders in a single color, so the
257
+ // "lit" element is the moving notch in the otherwise-grey square.
258
+ const SPINNER_FRAMES = ["⣾", "⣷", "⣯", "⣟", "⡿", "⢿", "⣻", "⣽"]
259
+ const SPINNER_TICK_MS = 120
260
+
261
+ /** Deterministic spinner frame for a timestamp (pure — testable). */
262
+ export function spinnerChar(nowMs: number) {
263
+ return SPINNER_FRAMES[
264
+ Math.floor(nowMs / SPINNER_TICK_MS) % SPINNER_FRAMES.length
265
+ ]!
266
+ }
267
+
268
+ export type ListenModel = {
269
+ projectName: string | null
270
+ branchName: string | null
271
+ metricName: string | null
272
+ metricUnit: string | null
273
+ metricDirection: "maximize" | "minimize" | null
274
+ bestValue: number | null
275
+ activity: string | null
276
+ /** True while an agent session is live — animates the activity spinner. */
277
+ active: boolean
278
+ /** Ascending by recency — the most recent experiment renders at the bottom. */
279
+ rows: ExperimentRow[]
280
+ pendingOutbox: number
281
+ syncedCount: number
282
+ }
283
+
284
+ /**
285
+ * Composes the full `onyx listen` frame as plain lines (no cursor control —
286
+ * commands/listen.ts adds per-line clears): a rounded box around the
287
+ * experiment table, the `ONYX | repo | branch` title interrupting the top
288
+ * border on the left (best metric on the right), with the activity line and
289
+ * sync footer below the box. Clipped to the terminal size.
290
+ */
291
+ export function renderFrame(
292
+ model: ListenModel,
293
+ size: { columns: number; rows: number; nowMs: number; color?: boolean }
294
+ ): string[] {
295
+ const columns = Math.max(40, size.columns)
296
+ const color = size.color ?? true
297
+ const inner = columns - 4
298
+
299
+ // Top border: ╭─ ONYX | repo | branch ───────── best ─╮
300
+ const best =
301
+ model.bestValue !== null && model.metricName
302
+ ? `best ${formatMetricValue(model.bestValue)} ${
303
+ model.metricDirection === "minimize" ? "↓" : "↑"
304
+ } ${model.metricName}${model.metricUnit ? ` ${model.metricUnit}` : ""}`
305
+ : ""
306
+ const title = truncate(
307
+ `ONYX | ${model.projectName ?? "(unlinked)"} | ${model.branchName ?? "(no onyx branch)"}`,
308
+ Math.max(8, columns - best.length - 12)
309
+ )
310
+ const fill = Math.max(
311
+ 1,
312
+ columns - 3 - title.length - 2 - (best ? best.length + 4 : 1)
313
+ )
314
+ const top = `${dim("╭─ ", color)}${bold(title, color)}${dim(
315
+ ` ${"─".repeat(fill)}${best ? `─ ${best} ─` : "─"}╮`,
316
+ color
317
+ )}`
318
+
319
+ const boxLine = (line: string) => {
320
+ const padding = " ".repeat(Math.max(0, inner - stripAnsi(line).length))
321
+ return `${dim("│ ", color)}${line}${padding}${dim(" │", color)}`
322
+ }
323
+
324
+ // Newest at the bottom: keep the tail when the table outgrows the screen.
325
+ const tableRows = Math.max(1, size.rows - 8)
326
+ const table = renderExperimentTable(model.rows.slice(-tableRows), {
327
+ columns: inner,
328
+ color,
329
+ nowMs: size.nowMs,
330
+ })
331
+ const body =
332
+ model.rows.length === 0
333
+ ? [table[0]!, dim("no experiments yet", color)]
334
+ : table
335
+
336
+ const bottom = dim(`╰${"─".repeat(columns - 2)}╯`, color)
337
+
338
+ // The notch orbits only while an agent is live; idle shows the full square.
339
+ const spinner = model.active ? spinnerChar(size.nowMs) : "⣿"
340
+ const activity = ` ${dim(spinner, color)} ${truncate(
341
+ `Research Agent: ${model.activity ?? "waiting for activity…"}`,
342
+ columns - 4
343
+ )}`
344
+ const footer = dim(
345
+ truncate(
346
+ ` outbox ${model.pendingOutbox} pending · ${model.syncedCount} synced · q quit`,
347
+ columns
348
+ ),
349
+ color
350
+ )
351
+
352
+ // Blank line above the box; blank box line below the title border; blank
353
+ // line above the footer.
354
+ return [
355
+ "",
356
+ top,
357
+ boxLine(""),
358
+ ...body.map(boxLine),
359
+ bottom,
360
+ activity,
361
+ "",
362
+ footer,
363
+ ]
364
+ }
package/src/main.ts ADDED
@@ -0,0 +1,84 @@
1
+ import packageJson from "../package.json"
2
+
3
+ import { commandAgent } from "./commands/agent"
4
+ import { parseArgs } from "./lib/args"
5
+ import { commandExpList, commandExpLog, commandExpRun } from "./commands/exp"
6
+ import { commandBranchCreate } from "./commands/branch"
7
+ import { commandListen } from "./commands/listen"
8
+ import { commandLogin } from "./commands/login"
9
+ import { commandProfile } from "./commands/profile"
10
+ import { commandPush, commandStatus, commandSync } from "./commands/sync"
11
+
12
+ export const USAGE = `onyx - research workflow CLI
13
+
14
+ Usage:
15
+ onyx --version
16
+ onyx login [--api-url <url>] [--print-url] [--refresh] [--port <port>] [--timeout <ms>]
17
+ onyx agent skill-path
18
+ onyx agent install-skill [--dir <path>] [--quiet]
19
+ onyx profile list
20
+ onyx profile use <name>
21
+ onyx profile set-api-key-env <name> <ENV_VAR>
22
+ onyx branch create --name <name> --metric <name> [--unit <unit>] [--direction maximize|minimize] [--description <text>] [--project-path <path>]
23
+ onyx exp run [--branch <name>] [--timeout <seconds>] [--checks-timeout <seconds>] [--project-path <path>] [--no-log]
24
+ onyx exp log [--branch <name>] [--name <name>] [--description <text>] [--agent-notes <json-or-text>] [--commit <sha>] [--metric <value>] [--metric-name <name>] [--status succeeded|failed|checks_failed|accepted|rejected|running|queued] [--project-path <path>]
25
+ onyx exp list [--branch <name>] [--status <status>] [--grep <regex>] [--limit <n>] [--json]
26
+ onyx listen
27
+ onyx status
28
+ onyx push
29
+ onyx sync [--project <id>] [--repository-url <url>] [--project-path <path>]
30
+
31
+ Results are reported to the Onyx app and queued in a local outbox
32
+ (.git/onyx/outbox.jsonl) when offline; \`onyx push\` and \`onyx sync\` flush the
33
+ queue and push the branch. \`onyx sync\` also refreshes the local history cache
34
+ (.git/onyx/history.jsonl) that \`onyx exp list\` searches offline; \`onyx listen\`
35
+ is a live read-only view of the current repo's research session.
36
+
37
+ Env:
38
+ ONYX_API_KEY overrides the selected profile API key
39
+ ONYX_API_URL overrides the selected profile API URL
40
+ Profiles may store a key locally or read it from apiKeyEnv
41
+ `
42
+
43
+ export async function main(argv = process.argv.slice(2)) {
44
+ const args = parseArgs(argv)
45
+ const command = args.positional[0]
46
+ const sub = args.positional[1]
47
+
48
+ try {
49
+ if (command === "login") return commandLogin(args)
50
+ if (command === "agent") return commandAgent(args)
51
+ if (command === "profile") return commandProfile(args)
52
+ if (command === "branch" && sub === "create")
53
+ return commandBranchCreate(args)
54
+ if (command === "exp" && sub === "run") return commandExpRun(args)
55
+ if (command === "exp" && sub === "log") return commandExpLog(args)
56
+ if (command === "exp" && sub === "list") return commandExpList(args)
57
+ if (command === "listen") return commandListen()
58
+ if (command === "status") return commandStatus(args)
59
+ if (command === "push") return commandPush(args)
60
+ if (command === "sync") return commandSync(args)
61
+
62
+ if (
63
+ args.options.version === "true" ||
64
+ command === "--version" ||
65
+ command === "-v" ||
66
+ command === "version"
67
+ ) {
68
+ console.log(packageJson.version)
69
+ return
70
+ }
71
+
72
+ if (command === "help" || command === "--help" || !command) {
73
+ console.log(USAGE)
74
+ return
75
+ }
76
+
77
+ console.error(`Unknown command: ${args.positional.join(" ")}`)
78
+ console.error(USAGE)
79
+ process.exit(1)
80
+ } catch (error) {
81
+ console.error(error instanceof Error ? error.message : String(error))
82
+ process.exit(1)
83
+ }
84
+ }