@matterailab/orbcode 0.1.3 → 0.1.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.
- package/README.md +73 -69
- package/dist/api/models.js +11 -0
- package/dist/auth/auth.js +7 -2
- package/dist/index.js +37 -1
- package/dist/ui/App.js +141 -14
- package/dist/ui/components/InputBox.js +12 -8
- package/dist/ui/components/StatusBar.js +42 -3
- package/dist/utils/updateCheck.js +162 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -7,6 +7,8 @@ models, the same native tool schemas, the same MatterAI auth backend — rebuilt
|
|
|
7
7
|
scratch as an interactive TUI with streaming chat, live thinking display, tool
|
|
8
8
|
activity rows, edit/command approvals, and todo tracking.
|
|
9
9
|
|
|
10
|
+

|
|
11
|
+
|
|
10
12
|
---
|
|
11
13
|
|
|
12
14
|
## Table of contents
|
|
@@ -144,12 +146,13 @@ The two Axon models are built in; `/model` opens a scroll-and-select picker
|
|
|
144
146
|
(`/model <id>` still selects directly). Additional models can be declared via
|
|
145
147
|
`customModels` in settings.json. The choice persists across sessions.
|
|
146
148
|
|
|
147
|
-
| id
|
|
148
|
-
|
|
149
|
-
| `axon-
|
|
150
|
-
| `axon-code-2-5-
|
|
149
|
+
| id | context | max output | pricing |
|
|
150
|
+
| ---------------------- | ------- | ---------- | ------------------ |
|
|
151
|
+
| `axon-eido-3-code-pro` | 400k | 64k | $3/M in · $9/M out |
|
|
152
|
+
| `axon-code-2-5-pro` | 400k | 64k | $2/M in · $6/M out |
|
|
153
|
+
| `axon-code-2-5-mini` | 400k | 64k | free |
|
|
151
154
|
|
|
152
|
-
`axon-code-2-5-pro` is the default.
|
|
155
|
+
`axon-code-2-5-pro` is the default. All three support native JSON tool calls and
|
|
153
156
|
image input. Cost comes from the API's usage chunks (`is_byok`-aware) and is
|
|
154
157
|
shown in the status bar.
|
|
155
158
|
|
|
@@ -196,35 +199,35 @@ shown in the status bar.
|
|
|
196
199
|
|
|
197
200
|
## Slash commands
|
|
198
201
|
|
|
199
|
-
| command
|
|
200
|
-
|
|
201
|
-
| `/help`
|
|
202
|
-
| `/model`
|
|
203
|
-
| `/clear`
|
|
204
|
-
| `/new`
|
|
205
|
-
| `/resume`
|
|
206
|
-
| `/analytics` | open the MatterAI analytics dashboard (app.matterai.so/orbital) in the browser
|
|
207
|
-
| `/compact`
|
|
208
|
-
| `/tasks`
|
|
209
|
-
| `/status`
|
|
210
|
-
| `/cost`
|
|
211
|
-
| `/init`
|
|
212
|
-
| `/login`
|
|
213
|
-
| `/logout`
|
|
214
|
-
| `/version`
|
|
215
|
-
| `/exit`
|
|
202
|
+
| command | action |
|
|
203
|
+
| ------------ | ----------------------------------------------------------------------------------------------------- |
|
|
204
|
+
| `/help` | list commands |
|
|
205
|
+
| `/model` | scrollable model picker (`/model pro` / `/model mini` / full id selects directly) |
|
|
206
|
+
| `/clear` | clear the screen only, like the terminal's `clear` — the conversation and context continue |
|
|
207
|
+
| `/new` | start a fresh conversation/session with a clean slate |
|
|
208
|
+
| `/resume` | pick a previous session for this directory and continue it (screen is cleared, conversation replayed) |
|
|
209
|
+
| `/analytics` | open the MatterAI analytics dashboard (app.matterai.so/orbital) in the browser |
|
|
210
|
+
| `/compact` | summarize the conversation and replace history with the summary |
|
|
211
|
+
| `/tasks` | print the current task list |
|
|
212
|
+
| `/status` | version, model, account, gateway, context usage, cost, approval modes |
|
|
213
|
+
| `/cost` | show session cost and fetch account balance |
|
|
214
|
+
| `/init` | analyze the codebase and create/improve `AGENTS.md` |
|
|
215
|
+
| `/login` | start the browser sign-in flow |
|
|
216
|
+
| `/logout` | remove the saved token |
|
|
217
|
+
| `/version` | print the CLI version |
|
|
218
|
+
| `/exit` | quit |
|
|
216
219
|
|
|
217
220
|
## Keyboard shortcuts
|
|
218
221
|
|
|
219
|
-
| key
|
|
220
|
-
|
|
221
|
-
| `Esc`
|
|
222
|
-
| `Ctrl+C`
|
|
223
|
-
| `Ctrl+O`
|
|
224
|
-
| `Shift+Tab`
|
|
225
|
-
| `↑` / `↓`
|
|
226
|
-
| `Ctrl+A` / `Ctrl+E` | start / end of line
|
|
227
|
-
| `Ctrl+U`
|
|
222
|
+
| key | action |
|
|
223
|
+
| ------------------- | ---------------------------------------------------------------------- |
|
|
224
|
+
| `Esc` | interrupt the running turn (or cancel login polling / close a menu) |
|
|
225
|
+
| `Ctrl+C` | quit |
|
|
226
|
+
| `Ctrl+O` | toggle thinking display for the whole transcript (past turns included) |
|
|
227
|
+
| `Shift+Tab` | cycle approval mode: ask → accept edits → auto-approve |
|
|
228
|
+
| `↑` / `↓` | input history, or navigate any open menu |
|
|
229
|
+
| `Ctrl+A` / `Ctrl+E` | start / end of line |
|
|
230
|
+
| `Ctrl+U` | clear the input line |
|
|
228
231
|
|
|
229
232
|
## Approvals & safety
|
|
230
233
|
|
|
@@ -267,22 +270,22 @@ Two kinds of files under `~/.orbcode/`:
|
|
|
267
270
|
|
|
268
271
|
```json
|
|
269
272
|
{
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
273
|
+
"apiKey": "<token used instead of logging in>",
|
|
274
|
+
"baseUrl": "https://my-gateway.example.com/v1",
|
|
275
|
+
"model": "my-custom-model",
|
|
276
|
+
"autoApproveEdits": false,
|
|
277
|
+
"autoApproveSafeCommands": false,
|
|
278
|
+
"customModels": [
|
|
279
|
+
{
|
|
280
|
+
"id": "my-custom-model",
|
|
281
|
+
"name": "My Custom Model",
|
|
282
|
+
"contextWindow": 128000,
|
|
283
|
+
"maxOutputTokens": 32000,
|
|
284
|
+
"inputPrice": 0.000001,
|
|
285
|
+
"outputPrice": 0.000002
|
|
286
|
+
}
|
|
287
|
+
],
|
|
288
|
+
"env": { "MY_VAR": "value" }
|
|
286
289
|
}
|
|
287
290
|
```
|
|
288
291
|
|
|
@@ -295,15 +298,15 @@ config.json.
|
|
|
295
298
|
Sessions are stored in `~/.orbcode/sessions/<id>.json` and power `/resume`
|
|
296
299
|
and `--resume <id>`.
|
|
297
300
|
|
|
298
|
-
| env var
|
|
299
|
-
|
|
300
|
-
| `ORBCODE_TOKEN`
|
|
301
|
-
| `ORBCODE_API_KEY`
|
|
302
|
-
| `ORBCODE_BASE_URL`
|
|
303
|
-
| `ORBCODE_MODEL`
|
|
304
|
-
| `ORBCODE_CONFIG_DIR`
|
|
305
|
-
| `ORBCODE_BACKEND_URL` | device-auth backend (default `https://api.matterai.so`)
|
|
306
|
-
| `ORBCODE_APP_URL`
|
|
301
|
+
| env var | effect |
|
|
302
|
+
| --------------------- | ----------------------------------------------------------------- |
|
|
303
|
+
| `ORBCODE_TOKEN` | auth token (overrides everything) |
|
|
304
|
+
| `ORBCODE_API_KEY` | same as `apiKey` in settings.json |
|
|
305
|
+
| `ORBCODE_BASE_URL` | same as `baseUrl` in settings.json |
|
|
306
|
+
| `ORBCODE_MODEL` | model override (what `--model` sets internally) |
|
|
307
|
+
| `ORBCODE_CONFIG_DIR` | config directory (default `~/.orbcode`) |
|
|
308
|
+
| `ORBCODE_BACKEND_URL` | device-auth backend (default `https://api.matterai.so`) |
|
|
309
|
+
| `ORBCODE_APP_URL` | webapp for the authorize page (default `https://app.matterai.so`) |
|
|
307
310
|
|
|
308
311
|
`autoApproveEdits` / `autoApproveSafeCommands` set the session defaults for the
|
|
309
312
|
approval prompts (dangerous commands still always prompt); shift+tab cycles
|
|
@@ -365,19 +368,19 @@ reset date — the same data as the extension's profile view.
|
|
|
365
368
|
|
|
366
369
|
Active in the CLI (schemas byte-identical to the extension's `native-tools`):
|
|
367
370
|
|
|
368
|
-
| tool
|
|
369
|
-
|
|
370
|
-
| `read_file`
|
|
371
|
-
| `file_edit`
|
|
372
|
-
| `multi_file_edit`
|
|
373
|
-
| `file_write`
|
|
374
|
-
| `list_files`
|
|
375
|
-
| `search_files`
|
|
376
|
-
| `execute_command`
|
|
377
|
-
| `web_search` / `web_fetch` | proxied through the MatterAI backend with your token
|
|
378
|
-
| `update_todo_list`
|
|
379
|
-
| `ask_followup_question`
|
|
380
|
-
| `attempt_completion`
|
|
371
|
+
| tool | executor notes |
|
|
372
|
+
| -------------------------- | -------------------------------------------------------------------------------------------- |
|
|
373
|
+
| `read_file` | line-numbered output (`LINE\|content`, 6-char pad), 1000-line cap, offset/limit |
|
|
374
|
+
| `file_edit` | single replacement; unique-match enforcement; `replace_all`; empty `old_string` = whole file |
|
|
375
|
+
| `multi_file_edit` | batched edits grouped per file, per-edit OK/FAILED results |
|
|
376
|
+
| `file_write` | creates parent dirs, full-content writes |
|
|
377
|
+
| `list_files` | optional recursive, ignores node_modules/.git/build dirs, 800-entry cap |
|
|
378
|
+
| `search_files` | JS regex search with glob `file_pattern` (picomatch), 300-match cap, binary skip |
|
|
379
|
+
| `execute_command` | user's shell, 120s timeout, 30k output cap, optional cwd |
|
|
380
|
+
| `web_search` / `web_fetch` | proxied through the MatterAI backend with your token |
|
|
381
|
+
| `update_todo_list` | drives the TUI todo panel |
|
|
382
|
+
| `ask_followup_question` | interactive menu in the TUI |
|
|
383
|
+
| `attempt_completion` | ends the turn with a completion card |
|
|
381
384
|
|
|
382
385
|
Present in `tools/schemas/` but **inactive** (need IDE services): `codebase_search`,
|
|
383
386
|
`lsp`, `list_code_definition_names`, `use_skill`, `check_past_chat_memories`,
|
|
@@ -417,6 +420,7 @@ Source-of-truth rule: behavior is **ported from the Orbital extension repo**
|
|
|
417
420
|
schemas byte-identical rather than editing them here.
|
|
418
421
|
|
|
419
422
|
Backend/web pieces of the device-auth flow live in:
|
|
423
|
+
|
|
420
424
|
- `gravity-console-backend` → `src/controller/orbcodeAuthController.ts`
|
|
421
425
|
(+ OAuth state in `router.ts`, callbacks in `authController.ts`)
|
|
422
426
|
- `gravity-console-webapp` → authorize dialog in `src/App.js`, sign-in q-p
|
package/dist/api/models.js
CHANGED
|
@@ -21,6 +21,17 @@ export const AXON_MODELS = {
|
|
|
21
21
|
outputPrice: 0.000006,
|
|
22
22
|
free: false,
|
|
23
23
|
},
|
|
24
|
+
"axon-eido-3-code-pro": {
|
|
25
|
+
id: "axon-eido-3-code-pro",
|
|
26
|
+
name: "Axon Eido 3 Pro",
|
|
27
|
+
description: "Axon Eido 3 Pro is the frontier Axon Code model for coding tasks, long running agents and general intelligence, fine-tuned on open source models.",
|
|
28
|
+
contextWindow: 400000,
|
|
29
|
+
maxOutputTokens: 64000,
|
|
30
|
+
supportsImages: true,
|
|
31
|
+
inputPrice: 0.000003,
|
|
32
|
+
outputPrice: 0.000009,
|
|
33
|
+
free: false,
|
|
34
|
+
},
|
|
24
35
|
};
|
|
25
36
|
export const DEFAULT_MODEL_ID = "axon-code-2-5-pro";
|
|
26
37
|
/** Add user-defined models (from settings.json) to the registry. */
|
package/dist/auth/auth.js
CHANGED
|
@@ -78,7 +78,10 @@ export async function pollDeviceAuth(devicecode) {
|
|
|
78
78
|
export async function fetchProfile(token) {
|
|
79
79
|
const url = getUrlFromToken("https://api.matterai.so/axoncode/profile", token);
|
|
80
80
|
const response = await fetch(url, {
|
|
81
|
-
headers: {
|
|
81
|
+
headers: {
|
|
82
|
+
Authorization: `Bearer ${token}`,
|
|
83
|
+
"Content-Type": "application/json",
|
|
84
|
+
},
|
|
82
85
|
});
|
|
83
86
|
if (!response.ok) {
|
|
84
87
|
throw new Error(`Profile request failed (${response.status}). Your token may be invalid or expired.`);
|
|
@@ -150,7 +153,9 @@ export async function fetchTaskTitle(taskId, token, maxRetries = 3, retryDelayMs
|
|
|
150
153
|
export async function fetchBalance(token, organizationId) {
|
|
151
154
|
try {
|
|
152
155
|
const url = getUrlFromToken("https://api.matterai.so/api/profile/balance", token);
|
|
153
|
-
const headers = {
|
|
156
|
+
const headers = {
|
|
157
|
+
Authorization: `Bearer ${token}`,
|
|
158
|
+
};
|
|
154
159
|
if (organizationId)
|
|
155
160
|
headers["X-KiloCode-OrganizationId"] = organizationId;
|
|
156
161
|
const response = await fetch(url, { headers });
|
package/dist/index.js
CHANGED
|
@@ -4,6 +4,8 @@ import { PRODUCT_NAME, VERSION } from "./branding.js";
|
|
|
4
4
|
import { App } from "./ui/App.js";
|
|
5
5
|
import { runHeadless } from "./headless.js";
|
|
6
6
|
import { loadSessionById } from "./core/sessions.js";
|
|
7
|
+
import { clearUpdateCache, compareVersions, fetchLatestNpmVersion, getGlobalInstallRoot, getUpdateInfo, isGlobalInstall, runNpmUpdate, } from "./utils/updateCheck.js";
|
|
8
|
+
const PACKAGE_NAME = "@matterailab/orbcode";
|
|
7
9
|
function printHelp() {
|
|
8
10
|
console.log(`${PRODUCT_NAME} v${VERSION}
|
|
9
11
|
|
|
@@ -11,6 +13,7 @@ Usage:
|
|
|
11
13
|
orbcode start an interactive session
|
|
12
14
|
orbcode "<prompt>" start an interactive session with an initial prompt
|
|
13
15
|
orbcode login sign in to MatterAI
|
|
16
|
+
orbcode update install the latest version from npm
|
|
14
17
|
orbcode -p "<prompt>" run a single prompt non-interactively (prints only the final response)
|
|
15
18
|
orbcode -p "…" --yolo non-interactive with auto-approved edits/commands
|
|
16
19
|
orbcode --model <id> use a specific model for this run
|
|
@@ -19,6 +22,32 @@ Usage:
|
|
|
19
22
|
orbcode --help show this help
|
|
20
23
|
`);
|
|
21
24
|
}
|
|
25
|
+
async function runUpdate() {
|
|
26
|
+
const latest = await fetchLatestNpmVersion(PACKAGE_NAME);
|
|
27
|
+
if (latest === null) {
|
|
28
|
+
console.error("Could not reach the npm registry. Check your network connection and try again.");
|
|
29
|
+
return 1;
|
|
30
|
+
}
|
|
31
|
+
if (compareVersions(VERSION, latest) >= 0) {
|
|
32
|
+
console.log(`${PRODUCT_NAME} v${VERSION} is already up to date (latest: v${latest}).`);
|
|
33
|
+
return 0;
|
|
34
|
+
}
|
|
35
|
+
console.log(`Updating ${PRODUCT_NAME} v${VERSION} → v${latest}…`);
|
|
36
|
+
const global = isGlobalInstall();
|
|
37
|
+
if (!global) {
|
|
38
|
+
const root = await getGlobalInstallRoot();
|
|
39
|
+
console.error(`This CLI was not installed globally (argv[1] = ${process.argv[1] || "<unknown>"}).\n` +
|
|
40
|
+
`To update a local/dev install, run \`npm install -g ${PACKAGE_NAME}@latest\` manually, or \`npm install\` inside the source checkout.` +
|
|
41
|
+
(root ? `\nGlobal install root detected at: ${root}` : ""));
|
|
42
|
+
return 1;
|
|
43
|
+
}
|
|
44
|
+
clearUpdateCache();
|
|
45
|
+
const code = await runNpmUpdate(PACKAGE_NAME);
|
|
46
|
+
if (code === 0) {
|
|
47
|
+
console.log(`Updated to v${latest}. Run \`${PRODUCT_NAME.split(" ")[0].toLowerCase()}\` again to use it.`);
|
|
48
|
+
}
|
|
49
|
+
return code;
|
|
50
|
+
}
|
|
22
51
|
/** Pop `flag <value>` (accepting -flag and --flag) out of args; returns the value. */
|
|
23
52
|
function takeFlagValue(args, name) {
|
|
24
53
|
const index = args.findIndex((a) => a === `--${name}` || a === `-${name}`);
|
|
@@ -42,6 +71,10 @@ async function main() {
|
|
|
42
71
|
printHelp();
|
|
43
72
|
return;
|
|
44
73
|
}
|
|
74
|
+
if (args[0] === "update") {
|
|
75
|
+
const code = await runUpdate();
|
|
76
|
+
process.exit(code);
|
|
77
|
+
}
|
|
45
78
|
const model = takeFlagValue(args, "model") ?? takeFlagValue(args, "m");
|
|
46
79
|
if (model) {
|
|
47
80
|
// loadSettings() treats ORBCODE_MODEL as the highest-precedence override,
|
|
@@ -76,7 +109,10 @@ async function main() {
|
|
|
76
109
|
process.stdout.write("\x1b[2J\x1b[H");
|
|
77
110
|
process.stdout.write("\x1b]0;orbcode\x07");
|
|
78
111
|
}
|
|
79
|
-
|
|
112
|
+
// Fire-and-forget: a stale or no-network state just means the header shows
|
|
113
|
+
// the "current version" line instead of an upgrade prompt.
|
|
114
|
+
const updateCheckPromise = getUpdateInfo(PACKAGE_NAME, VERSION);
|
|
115
|
+
render(_jsx(App, { initialView: initialView, initialPrompt: initialPrompt, initialSession: initialSession, updateCheck: updateCheckPromise }));
|
|
80
116
|
}
|
|
81
117
|
main().catch((error) => {
|
|
82
118
|
console.error(error);
|
package/dist/ui/App.js
CHANGED
|
@@ -41,6 +41,28 @@ function setTerminalTitle(title) {
|
|
|
41
41
|
process.stdout.write(`\x1b]0;${title}\x07`);
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
|
+
function formatRelativeTime(isoStr) {
|
|
45
|
+
if (!isoStr)
|
|
46
|
+
return "???";
|
|
47
|
+
const now = Date.now();
|
|
48
|
+
const target = new Date(isoStr).getTime();
|
|
49
|
+
if (Number.isNaN(target))
|
|
50
|
+
return "???";
|
|
51
|
+
const diff = target - now;
|
|
52
|
+
if (diff <= 0)
|
|
53
|
+
return "now";
|
|
54
|
+
const sec = Math.floor(diff / 1000);
|
|
55
|
+
const min = Math.floor(sec / 60);
|
|
56
|
+
const hrs = Math.floor(min / 60);
|
|
57
|
+
const days = Math.floor(hrs / 24);
|
|
58
|
+
if (days >= 1)
|
|
59
|
+
return `in ${days} day${days > 1 ? "s" : ""}`;
|
|
60
|
+
if (hrs >= 1)
|
|
61
|
+
return `in ${hrs}h ${min % 60}m`;
|
|
62
|
+
if (min >= 1)
|
|
63
|
+
return `in ${min}m`;
|
|
64
|
+
return "soon";
|
|
65
|
+
}
|
|
44
66
|
/** Human lines for the /status and /cost usage block (extension profile data). */
|
|
45
67
|
function usageLines(profile) {
|
|
46
68
|
const lines = [];
|
|
@@ -52,8 +74,22 @@ function usageLines(profile) {
|
|
|
52
74
|
if (typeof profile.remainingReviews === "number") {
|
|
53
75
|
lines.push(`Reviews ${profile.remainingReviews} remaining`);
|
|
54
76
|
}
|
|
55
|
-
if (profile.
|
|
77
|
+
if (profile.tieredUsage) {
|
|
78
|
+
const ws = [
|
|
79
|
+
["5hr", "5-Hour", profile.tieredUsage.fiveHour],
|
|
80
|
+
["wk", "Weekly", profile.tieredUsage.weekly],
|
|
81
|
+
["mo", "Monthly", profile.tieredUsage.monthly],
|
|
82
|
+
];
|
|
83
|
+
for (const [short, label, w] of ws) {
|
|
84
|
+
const remaining = Math.max(0, w.remaining || 0);
|
|
85
|
+
const pct = Math.max(0, Math.min(100, w.percentage || 0));
|
|
86
|
+
const reset = formatRelativeTime(w.resetsAt);
|
|
87
|
+
lines.push(`${label.padEnd(12)} ${remaining.toFixed(1)}/${w.limit.toFixed(1)} left (${pct}% used) · Resets ${reset}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
else if (profile.creditsResetDate) {
|
|
56
91
|
lines.push(`Resets ${profile.creditsResetDate}`);
|
|
92
|
+
}
|
|
57
93
|
return lines;
|
|
58
94
|
}
|
|
59
95
|
const INIT_PROMPT = `Analyze this codebase and create an AGENTS.md file containing:
|
|
@@ -106,10 +142,11 @@ let rowCounter = 0;
|
|
|
106
142
|
function rowId() {
|
|
107
143
|
return `row-${rowCounter++}`;
|
|
108
144
|
}
|
|
109
|
-
export function App({ initialView, initialPrompt, initialSession, }) {
|
|
145
|
+
export function App({ initialView, initialPrompt, initialSession, updateCheck, }) {
|
|
110
146
|
const { exit } = useApp();
|
|
111
147
|
const [settings, setSettings] = useState(() => loadSettings());
|
|
112
148
|
const [view, setView] = useState(initialView ?? (getAuthToken(settings) ? "chat" : "login"));
|
|
149
|
+
const [updateInfo, setUpdateInfo] = useState(null);
|
|
113
150
|
const [rows, setRows] = useState(() => [
|
|
114
151
|
{ kind: "header", id: "header", cwd: process.cwd(), modelName: getModel(loadSettings().model).name },
|
|
115
152
|
]);
|
|
@@ -120,6 +157,10 @@ export function App({ initialView, initialPrompt, initialSession, }) {
|
|
|
120
157
|
const [runningTool, setRunningTool] = useState(null);
|
|
121
158
|
const [pendingApproval, setPendingApproval] = useState(null);
|
|
122
159
|
const [pendingFollowup, setPendingFollowup] = useState(null);
|
|
160
|
+
// FIFO queue of messages the user typed while the LLM was still streaming.
|
|
161
|
+
// Drained one-per-turn on each `turn-end` event so multi-step work can
|
|
162
|
+
// keep flowing without making the user wait for the previous response.
|
|
163
|
+
const [queuedMessages, setQueuedMessages] = useState([]);
|
|
123
164
|
const [modelPickerOpen, setModelPickerOpen] = useState(false);
|
|
124
165
|
const [resumableSessions, setResumableSessions] = useState(null);
|
|
125
166
|
const [staticKey, setStaticKey] = useState(0);
|
|
@@ -139,7 +180,7 @@ export function App({ initialView, initialPrompt, initialSession, }) {
|
|
|
139
180
|
if (!token)
|
|
140
181
|
return;
|
|
141
182
|
fetchProfile(token)
|
|
142
|
-
.then((profile) => setUsage({ plan: profile.plan, usagePercentage: profile.usagePercentage }))
|
|
183
|
+
.then((profile) => setUsage({ plan: profile.plan, usagePercentage: profile.usagePercentage, tieredUsage: profile.tieredUsage }))
|
|
143
184
|
.catch(() => { });
|
|
144
185
|
}, []);
|
|
145
186
|
const agentRef = useRef(null);
|
|
@@ -148,6 +189,26 @@ export function App({ initialView, initialPrompt, initialSession, }) {
|
|
|
148
189
|
const textBufferRef = useRef("");
|
|
149
190
|
// taskId for which a title fetch has already been started (once per task).
|
|
150
191
|
const titleTaskRef = useRef(null);
|
|
192
|
+
// Mirror of `queuedMessages` for the agent event handler (kept on a ref so
|
|
193
|
+
// we can drain it inside `handleEvent` without re-creating that callback
|
|
194
|
+
// on every keystroke).
|
|
195
|
+
const queueRef = useRef([]);
|
|
196
|
+
const enqueueMessage = useCallback((text) => {
|
|
197
|
+
queueRef.current = [...queueRef.current, text];
|
|
198
|
+
setQueuedMessages(queueRef.current);
|
|
199
|
+
}, []);
|
|
200
|
+
const drainQueue = useCallback(() => {
|
|
201
|
+
if (queueRef.current.length === 0)
|
|
202
|
+
return null;
|
|
203
|
+
const [next, ...rest] = queueRef.current;
|
|
204
|
+
queueRef.current = rest;
|
|
205
|
+
setQueuedMessages(rest);
|
|
206
|
+
return next;
|
|
207
|
+
}, []);
|
|
208
|
+
const clearQueue = useCallback(() => {
|
|
209
|
+
queueRef.current = [];
|
|
210
|
+
setQueuedMessages([]);
|
|
211
|
+
}, []);
|
|
151
212
|
const maybeFetchTitle = useCallback(() => {
|
|
152
213
|
const agent = agentRef.current;
|
|
153
214
|
if (!agent || titleTaskRef.current === agent.taskId)
|
|
@@ -227,6 +288,10 @@ export function App({ initialView, initialPrompt, initialSession, }) {
|
|
|
227
288
|
case "usage":
|
|
228
289
|
setContextTokens(event.inputTokens + event.outputTokens);
|
|
229
290
|
setTotalCost(event.totalCost);
|
|
291
|
+
// A usage chunk arrives once per LLM response, so the plan/usage
|
|
292
|
+
// shown below the chat box stays current mid-turn, not only
|
|
293
|
+
// after the turn finishes.
|
|
294
|
+
refreshUsage();
|
|
230
295
|
break;
|
|
231
296
|
case "completion":
|
|
232
297
|
pushRow({ kind: "completion", text: event.result });
|
|
@@ -249,9 +314,25 @@ export function App({ initialView, initialPrompt, initialSession, }) {
|
|
|
249
314
|
setBusy(false);
|
|
250
315
|
maybeFetchTitle();
|
|
251
316
|
refreshUsage();
|
|
317
|
+
// Pop the next queued message and immediately start a new
|
|
318
|
+
// turn on top of the one that just ended. The agent's
|
|
319
|
+
// `runTurn` is fully resolved at this point (its
|
|
320
|
+
// `finally` emitted this event), so the new turn picks
|
|
321
|
+
// up the up-to-date conversation history. We use
|
|
322
|
+
// `agentRef` directly here to avoid a circular
|
|
323
|
+
// `handleEvent` ↔ `getAgent` reference; the ref is
|
|
324
|
+
// guaranteed populated because `runTurn` set it
|
|
325
|
+
// synchronously before emitting this event.
|
|
326
|
+
const nextQueued = drainQueue();
|
|
327
|
+
if (nextQueued !== null && agentRef.current) {
|
|
328
|
+
pushRow({ kind: "user", text: nextQueued });
|
|
329
|
+
setBusy(true);
|
|
330
|
+
setBusyLabel("Thinking");
|
|
331
|
+
void agentRef.current.runTurn(nextQueued);
|
|
332
|
+
}
|
|
252
333
|
break;
|
|
253
334
|
}
|
|
254
|
-
}, [pushRow, maybeFetchTitle, refreshUsage]);
|
|
335
|
+
}, [pushRow, maybeFetchTitle, refreshUsage, drainQueue]);
|
|
255
336
|
const createAgent = useCallback((resume) => {
|
|
256
337
|
const current = loadSettings();
|
|
257
338
|
return new Agent({
|
|
@@ -324,11 +405,20 @@ export function App({ initialView, initialPrompt, initialSession, }) {
|
|
|
324
405
|
if (arg && isValidAxonModel(arg)) {
|
|
325
406
|
switchModel(arg);
|
|
326
407
|
}
|
|
327
|
-
else if (arg && isValidAxonModel(`axon-code-2-5-${arg}`)) {
|
|
328
|
-
switchModel(`axon-code-2-5-${arg}`);
|
|
329
|
-
}
|
|
330
408
|
else if (arg) {
|
|
331
|
-
|
|
409
|
+
// Allow short suffixes like "pro" or "mini" to resolve to the
|
|
410
|
+
// latest matching registered id, so /model pro keeps working
|
|
411
|
+
// as new model generations are added.
|
|
412
|
+
const matches = ids
|
|
413
|
+
.filter((id) => id.endsWith(`-${arg}`))
|
|
414
|
+
.sort()
|
|
415
|
+
.reverse();
|
|
416
|
+
if (matches.length > 0) {
|
|
417
|
+
switchModel(matches[0]);
|
|
418
|
+
}
|
|
419
|
+
else {
|
|
420
|
+
pushRow({ kind: "error", text: `Unknown model "${arg}". Available: ${ids.join(", ")}` });
|
|
421
|
+
}
|
|
332
422
|
}
|
|
333
423
|
else {
|
|
334
424
|
setModelPickerOpen(true);
|
|
@@ -342,6 +432,7 @@ export function App({ initialView, initialPrompt, initialSession, }) {
|
|
|
342
432
|
break;
|
|
343
433
|
case "/new":
|
|
344
434
|
// Drop the agent entirely so the next message starts a fresh session.
|
|
435
|
+
clearQueue();
|
|
345
436
|
agentRef.current = null;
|
|
346
437
|
titleTaskRef.current = null;
|
|
347
438
|
setSessionTitle("");
|
|
@@ -465,6 +556,7 @@ export function App({ initialView, initialPrompt, initialSession, }) {
|
|
|
465
556
|
setView("login");
|
|
466
557
|
break;
|
|
467
558
|
case "/logout": {
|
|
559
|
+
clearQueue();
|
|
468
560
|
const updated = { ...settings, token: undefined };
|
|
469
561
|
setSettings(updated);
|
|
470
562
|
saveSettings(updated);
|
|
@@ -478,7 +570,7 @@ export function App({ initialView, initialPrompt, initialSession, }) {
|
|
|
478
570
|
default:
|
|
479
571
|
pushRow({ kind: "error", text: `Unknown command: ${name}. Try /help.` });
|
|
480
572
|
}
|
|
481
|
-
}, [settings, tasks, contextTokens, totalCost, sessionTitle, exit, pushRow, getAgent, switchModel, resetTranscript]);
|
|
573
|
+
}, [settings, tasks, contextTokens, totalCost, sessionTitle, exit, pushRow, getAgent, switchModel, resetTranscript, clearQueue]);
|
|
482
574
|
const handleSubmit = useCallback((value) => {
|
|
483
575
|
if (value.startsWith("/")) {
|
|
484
576
|
handleCommand(value);
|
|
@@ -488,11 +580,18 @@ export function App({ initialView, initialPrompt, initialSession, }) {
|
|
|
488
580
|
setView("login");
|
|
489
581
|
return;
|
|
490
582
|
}
|
|
583
|
+
// While the LLM is still streaming, hold the message in a FIFO
|
|
584
|
+
// queue instead of dropping it on the floor. `handleEvent`'s
|
|
585
|
+
// `turn-end` case drains the queue and starts the next turn.
|
|
586
|
+
if (busy) {
|
|
587
|
+
enqueueMessage(value);
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
491
590
|
pushRow({ kind: "user", text: value });
|
|
492
591
|
setBusy(true);
|
|
493
592
|
setBusyLabel("Thinking");
|
|
494
593
|
void getAgent().runTurn(value);
|
|
495
|
-
}, [settings
|
|
594
|
+
}, [settings, busy, handleCommand, enqueueMessage, getAgent, pushRow]);
|
|
496
595
|
// Apply --resume and an initial prompt (`orbcode "do something"`) on startup.
|
|
497
596
|
const bootedRef = useRef(false);
|
|
498
597
|
useEffect(() => {
|
|
@@ -505,7 +604,29 @@ export function App({ initialView, initialPrompt, initialSession, }) {
|
|
|
505
604
|
handleSubmit(initialPrompt);
|
|
506
605
|
refreshUsage();
|
|
507
606
|
}, [initialSession, initialPrompt, handleResume, handleSubmit, refreshUsage]);
|
|
607
|
+
// Resolve the npm version check after first paint so the TUI shows up
|
|
608
|
+
// immediately and the upgrade banner fades in once we know the answer.
|
|
609
|
+
useEffect(() => {
|
|
610
|
+
if (!updateCheck)
|
|
611
|
+
return;
|
|
612
|
+
let cancelled = false;
|
|
613
|
+
updateCheck.then((info) => {
|
|
614
|
+
if (!cancelled)
|
|
615
|
+
setUpdateInfo(info);
|
|
616
|
+
});
|
|
617
|
+
return () => {
|
|
618
|
+
cancelled = true;
|
|
619
|
+
};
|
|
620
|
+
}, [updateCheck]);
|
|
508
621
|
useInput((input, key) => {
|
|
622
|
+
// Ctrl+D quits from any view (chat, login, busy, picker, approval,
|
|
623
|
+
// followup). InputBox swallows other Ctrl-combos, but this hook is a
|
|
624
|
+
// sibling of InputBox's hook, so it still sees the key.
|
|
625
|
+
if (key.ctrl && input === "d") {
|
|
626
|
+
agentRef.current?.abort();
|
|
627
|
+
exit();
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
509
630
|
if (key.escape && busy && !pendingApproval && !pendingFollowup) {
|
|
510
631
|
agentRef.current?.abort();
|
|
511
632
|
}
|
|
@@ -539,13 +660,13 @@ export function App({ initialView, initialPrompt, initialSession, }) {
|
|
|
539
660
|
saveSettings(updated);
|
|
540
661
|
agentRef.current = null;
|
|
541
662
|
setView("chat");
|
|
542
|
-
setUsage({ plan: profile.plan, usagePercentage: profile.usagePercentage });
|
|
663
|
+
setUsage({ plan: profile.plan, usagePercentage: profile.usagePercentage, tieredUsage: profile.tieredUsage });
|
|
543
664
|
const who = profile.user?.name || profile.user?.email;
|
|
544
665
|
pushRow({ kind: "info", text: `Signed in${who ? ` as ${who}` : ""}. Ready when you are.` });
|
|
545
666
|
}, [pushRow]);
|
|
546
667
|
const taskLines = useMemo(() => tasks.split("\n").map((l) => l.trim()).filter(Boolean), [tasks]);
|
|
547
|
-
const inputActive = view === "chat" && !
|
|
548
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: rows, children: (row) => _jsx(RowView, { row: row }, row.id) }, staticKey), view === "login" ? (_jsx(LoginSection, { onLogin: handleLogin })) : (_jsxs(Box, { flexDirection: "column", children: [streamingReasoning && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: COLORS.thinking, italic: true, children: "\u2726 Thinking\u2026" }), _jsx(Box, { paddingLeft: 2, children: _jsx(Text, { dimColor: true, italic: true, children: tail(streamingReasoning, expandReasoningRef.current ? 30 : 3) }) })] })), streamingText && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { children: [_jsx(Text, { color: COLORS.primary, children: "\u25CF " }), streamingText] }) })), runningTool && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: COLORS.warning, children: [formatToolName(runningTool.name), " ", _jsx(Text, { dimColor: true, children: runningTool.summary })] }) })), taskLines.length > 0 && (_jsxs(Box, { flexDirection: "column", marginTop: 1, paddingLeft: 1, children: [_jsx(Text, { dimColor: true, bold: true, children: "Tasks" }), taskLines.slice(0, 10).map((line, i) => (_jsx(Text, { dimColor: /^[-*]\s*\[x\]/i.test(line), children: line
|
|
668
|
+
const inputActive = view === "chat" && !pendingApproval && !pendingFollowup && !modelPickerOpen && !resumableSessions;
|
|
669
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: rows, children: (row) => _jsx(RowView, { row: row }, row.id) }, staticKey), view === "login" ? (_jsx(LoginSection, { onLogin: handleLogin })) : (_jsxs(Box, { flexDirection: "column", children: [updateInfo?.updateAvailable && updateInfo.latest && (_jsxs(Box, { marginTop: 1, flexDirection: "column", borderStyle: "round", borderColor: COLORS.warning, paddingX: 2, alignSelf: "flex-start", children: [_jsxs(Text, { color: COLORS.warning, bold: true, children: ["\u2191 Update available: v", updateInfo.current, " \u2192 v", updateInfo.latest] }), _jsxs(Text, { children: ["Run ", _jsx(Text, { color: COLORS.accent, children: "orbcode update" }), " to install the latest version, then relaunch."] })] })), streamingReasoning && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: COLORS.thinking, italic: true, children: "\u2726 Thinking\u2026" }), _jsx(Box, { paddingLeft: 2, children: _jsx(Text, { dimColor: true, italic: true, children: tail(streamingReasoning, expandReasoningRef.current ? 30 : 3) }) })] })), streamingText && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { children: [_jsx(Text, { color: COLORS.primary, children: "\u25CF " }), streamingText] }) })), runningTool && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: COLORS.warning, children: [formatToolName(runningTool.name), " ", _jsx(Text, { dimColor: true, children: runningTool.summary })] }) })), taskLines.length > 0 && (_jsxs(Box, { flexDirection: "column", marginTop: 1, paddingLeft: 1, children: [_jsx(Text, { dimColor: true, bold: true, children: "Tasks" }), taskLines.slice(0, 10).map((line, i) => (_jsx(Text, { dimColor: /^[-*]\s*\[x\]/i.test(line), children: line
|
|
549
670
|
.replace(/^[-*]\s*\[x\]/i, " ■")
|
|
550
671
|
.replace(/^[-*]\s*\[-\]/, " ◧")
|
|
551
672
|
.replace(/^[-*]\s*\[ \]/, " □") }, i))), taskLines.length > 10 && _jsxs(Text, { dimColor: true, children: [" \u2026 ", taskLines.length - 10, " more"] })] })), modelPickerOpen && (_jsx(Box, { marginTop: 1, children: _jsx(ModelPicker, { currentId: settings.model, onSelect: (modelId) => {
|
|
@@ -558,12 +679,18 @@ export function App({ initialView, initialPrompt, initialSession, }) {
|
|
|
558
679
|
pushRow({ kind: "user", text: answer });
|
|
559
680
|
pendingFollowup.resolve(answer);
|
|
560
681
|
setPendingFollowup(null);
|
|
561
|
-
} }) })), busy && !pendingApproval && !pendingFollowup && !streamingText && (_jsx(Box, { marginTop: 1, children: _jsx(Spinner, { label: busyLabel }) })), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(InputBox, { active: inputActive, slashCommands: SLASH_COMMANDS, onSubmit: handleSubmit }), _jsx(StatusBar, { modelId: settings.model, contextTokens: contextTokens, totalCost: totalCost, state: busy ? busyLabel : "", approvalMode: approvalMode, busy: busy, title: sessionTitle, plan: usage?.plan, usagePercentage: usage?.usagePercentage })] })] }))] }));
|
|
682
|
+
} }) })), busy && !pendingApproval && !pendingFollowup && !streamingText && (_jsx(Box, { marginTop: 1, children: _jsx(Spinner, { label: busyLabel }) })), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [queuedMessages.length > 0 && (_jsxs(Box, { flexDirection: "column", paddingLeft: 1, marginBottom: 1, children: [_jsxs(Text, { dimColor: true, bold: true, children: ["Queue (", queuedMessages.length, ")"] }), queuedMessages.slice(0, 5).map((msg, i) => (_jsxs(Text, { dimColor: true, children: [i + 1, ". ", truncateForQueue(msg).replace(/\n/g, "↵")] }, i))), queuedMessages.length > 5 && (_jsxs(Text, { dimColor: true, children: [" \u2026 ", queuedMessages.length - 5, " more"] }))] })), _jsx(InputBox, { active: inputActive, slashCommands: SLASH_COMMANDS, onSubmit: handleSubmit }), _jsx(StatusBar, { modelId: settings.model, contextTokens: contextTokens, totalCost: totalCost, state: busy ? busyLabel : "", approvalMode: approvalMode, busy: busy, title: sessionTitle, plan: usage?.plan, usagePercentage: usage?.usagePercentage, tieredUsage: usage?.tieredUsage })] })] }))] }));
|
|
562
683
|
}
|
|
563
684
|
function tail(text, lines) {
|
|
564
685
|
const all = text.split("\n").filter((l) => l.trim());
|
|
565
686
|
return all.slice(-lines).join("\n");
|
|
566
687
|
}
|
|
688
|
+
const QUEUE_PREVIEW_LIMIT = 80;
|
|
689
|
+
function truncateForQueue(text) {
|
|
690
|
+
if (text.length <= QUEUE_PREVIEW_LIMIT)
|
|
691
|
+
return text;
|
|
692
|
+
return text.slice(0, QUEUE_PREVIEW_LIMIT - 1) + "…";
|
|
693
|
+
}
|
|
567
694
|
function LoginSection({ onLogin }) {
|
|
568
695
|
return _jsx(LoginView, { onLogin: onLogin });
|
|
569
696
|
}
|
|
@@ -147,6 +147,14 @@ export function InputBox({ active, slashCommands, onSubmit }) {
|
|
|
147
147
|
return;
|
|
148
148
|
}
|
|
149
149
|
}
|
|
150
|
+
if (key.shift && key.return) {
|
|
151
|
+
// Shift+Enter inserts a literal newline without submitting.
|
|
152
|
+
setHistoryIndex(-1);
|
|
153
|
+
const next = value.slice(0, cursor) + "\n" + value.slice(cursor);
|
|
154
|
+
setValue(next);
|
|
155
|
+
setCursor((c) => c + 1);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
150
158
|
if (key.return) {
|
|
151
159
|
submit(value);
|
|
152
160
|
return;
|
|
@@ -198,15 +206,11 @@ export function InputBox({ active, slashCommands, onSubmit }) {
|
|
|
198
206
|
return;
|
|
199
207
|
}
|
|
200
208
|
if (input) {
|
|
201
|
-
// Multi-char chunks (paste)
|
|
202
|
-
//
|
|
203
|
-
|
|
204
|
-
const clean = input.replace(
|
|
209
|
+
// Multi-char chunks (paste) are inserted as-is. Newlines inside
|
|
210
|
+
// the chunk are literal newlines, not a submit signal — press
|
|
211
|
+
// Enter when you're ready to send.
|
|
212
|
+
const clean = input.replace(/\r\n?/g, "\n");
|
|
205
213
|
const next = value.slice(0, cursor) + clean + value.slice(cursor);
|
|
206
|
-
if (endsWithNewline) {
|
|
207
|
-
submit(next);
|
|
208
|
-
return;
|
|
209
|
-
}
|
|
210
214
|
setHistoryIndex(-1);
|
|
211
215
|
setValue(next);
|
|
212
216
|
setCursor((c) => c + clean.length);
|
|
@@ -7,12 +7,51 @@ const MODE_LABELS = {
|
|
|
7
7
|
edits: "⏵⏵ accept edits on",
|
|
8
8
|
auto: "⏵⏵⏵ auto-approve on",
|
|
9
9
|
};
|
|
10
|
+
function formatRelativeTime(isoStr) {
|
|
11
|
+
if (!isoStr)
|
|
12
|
+
return "???";
|
|
13
|
+
const now = Date.now();
|
|
14
|
+
const target = new Date(isoStr).getTime();
|
|
15
|
+
if (Number.isNaN(target))
|
|
16
|
+
return "???";
|
|
17
|
+
const diff = target - now;
|
|
18
|
+
if (diff <= 0)
|
|
19
|
+
return "now";
|
|
20
|
+
const sec = Math.floor(diff / 1000);
|
|
21
|
+
const min = Math.floor(sec / 60);
|
|
22
|
+
const hrs = Math.floor(min / 60);
|
|
23
|
+
const days = Math.floor(hrs / 24);
|
|
24
|
+
if (days >= 1)
|
|
25
|
+
return `${days}d`;
|
|
26
|
+
if (hrs >= 1)
|
|
27
|
+
return `${hrs}h${min % 60 > 0 ? ` ${min % 60}m` : ""}`;
|
|
28
|
+
if (min >= 1)
|
|
29
|
+
return `${min}m`;
|
|
30
|
+
return "soon";
|
|
31
|
+
}
|
|
10
32
|
function truncate(text, max) {
|
|
11
33
|
return text.length > max ? text.slice(0, max - 1) + "…" : text;
|
|
12
34
|
}
|
|
13
|
-
|
|
35
|
+
/**
|
|
36
|
+
* Picks the most constrained window (highest percentage used) and returns a
|
|
37
|
+
* compact string like "5hr 12% · resets in 3h 15m".
|
|
38
|
+
*/
|
|
39
|
+
function mostConstrainedSummary(tu) {
|
|
40
|
+
const windows = [
|
|
41
|
+
["5hr", tu.fiveHour.percentage, tu.fiveHour.resetsAt],
|
|
42
|
+
["wk", tu.weekly.percentage, tu.weekly.resetsAt],
|
|
43
|
+
["mo", tu.monthly.percentage, tu.monthly.resetsAt],
|
|
44
|
+
];
|
|
45
|
+
const [label, pct, resetsAt] = windows.reduce((best, cur) => cur[1] >= best[1] ? cur : best);
|
|
46
|
+
return `${label} ${Math.round(pct)}% · resets ${formatRelativeTime(resetsAt)}`;
|
|
47
|
+
}
|
|
48
|
+
export function StatusBar({ modelId, contextTokens, totalCost, state, approvalMode, busy, title, plan, usagePercentage, tieredUsage, }) {
|
|
14
49
|
const model = getModel(modelId);
|
|
15
50
|
const contextPct = Math.min(100, Math.round((contextTokens / model.contextWindow) * 100));
|
|
16
|
-
const
|
|
17
|
-
|
|
51
|
+
const constrainedLine = tieredUsage
|
|
52
|
+
? mostConstrainedSummary(tieredUsage)
|
|
53
|
+
: typeof usagePercentage === "number"
|
|
54
|
+
? `${Math.round(usagePercentage)}% used`
|
|
55
|
+
: null;
|
|
56
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Text, { dimColor: true, children: [_jsx(Text, { color: approvalMode === "ask" ? undefined : COLORS.warning, children: MODE_LABELS[approvalMode] }), " (shift+tab to cycle)", busy && " · esc to interrupt", state ? _jsxs(Text, { color: COLORS.thinking, children: [" \u00B7 ", state] }) : null] }), _jsxs(Text, { dimColor: true, children: [title ? `${truncate(title, 32)} · ` : "", model.name, " \u00B7 ctx ", contextTokens.toLocaleString(), " (", contextPct, "%)", model.free ? " · free" : ` · $${totalCost.toFixed(4)}`] })] }), (plan || constrainedLine) && (_jsx(Box, { justifyContent: "flex-end", children: _jsxs(Text, { dimColor: true, children: [plan && constrainedLine ? " · " : "", constrainedLine] }) }))] }));
|
|
18
57
|
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { execFile, spawn } from "node:child_process";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { getConfigDir } from "../config/settings.js";
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
/** Network timeout when hitting the npm registry. Kept short so it never blocks startup. */
|
|
8
|
+
const REGISTRY_TIMEOUT_MS = 3_000;
|
|
9
|
+
/** Don't re-hit npm more than once per TTL window. */
|
|
10
|
+
const CACHE_TTL_MS = 60 * 60 * 1_000; // 1 hour
|
|
11
|
+
/** Hit the npm registry to learn what the latest published version is. */
|
|
12
|
+
export async function fetchLatestNpmVersion(pkg, timeoutMs = REGISTRY_TIMEOUT_MS) {
|
|
13
|
+
// encodeURIComponent turns "@matterailab/orbcode" into "@matterailab%2Forbcode",
|
|
14
|
+
// which is the form the registry URL expects for scoped packages.
|
|
15
|
+
const url = `https://registry.npmjs.org/${encodeURIComponent(pkg)}/latest`;
|
|
16
|
+
const controller = new AbortController();
|
|
17
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
18
|
+
try {
|
|
19
|
+
const res = await fetch(url, {
|
|
20
|
+
signal: controller.signal,
|
|
21
|
+
headers: { accept: "application/json" },
|
|
22
|
+
});
|
|
23
|
+
if (!res.ok)
|
|
24
|
+
return null;
|
|
25
|
+
const data = (await res.json());
|
|
26
|
+
return typeof data.version === "string" ? data.version : null;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
finally {
|
|
32
|
+
clearTimeout(timer);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Compare two semver strings (X.Y.Z, optionally with a pre-release suffix).
|
|
37
|
+
* Returns -1 if a < b, 0 if equal, 1 if a > b. Non-numeric segments fall back
|
|
38
|
+
* to lexicographic compare so "0.1.4-beta" sorts before "0.1.4".
|
|
39
|
+
*/
|
|
40
|
+
export function compareVersions(a, b) {
|
|
41
|
+
const [aMain, aPre] = splitVersion(a);
|
|
42
|
+
const [bMain, bPre] = splitVersion(b);
|
|
43
|
+
for (let i = 0; i < 3; i++) {
|
|
44
|
+
const ai = aMain[i] ?? 0;
|
|
45
|
+
const bi = bMain[i] ?? 0;
|
|
46
|
+
if (ai !== bi)
|
|
47
|
+
return ai < bi ? -1 : 1;
|
|
48
|
+
}
|
|
49
|
+
// A version with no pre-release is greater than one with one.
|
|
50
|
+
if (aPre === bPre)
|
|
51
|
+
return 0;
|
|
52
|
+
if (aPre === "")
|
|
53
|
+
return 1;
|
|
54
|
+
if (bPre === "")
|
|
55
|
+
return -1;
|
|
56
|
+
return aPre < bPre ? -1 : 1;
|
|
57
|
+
}
|
|
58
|
+
function splitVersion(v) {
|
|
59
|
+
const [main, pre = ""] = v.split("-", 2);
|
|
60
|
+
const parts = main.split(".").map((p) => {
|
|
61
|
+
const n = Number.parseInt(p, 10);
|
|
62
|
+
return Number.isFinite(n) ? n : 0;
|
|
63
|
+
});
|
|
64
|
+
while (parts.length < 3)
|
|
65
|
+
parts.push(0);
|
|
66
|
+
return [parts, pre];
|
|
67
|
+
}
|
|
68
|
+
function getCachePath() {
|
|
69
|
+
return path.join(getConfigDir(), "update-check.json");
|
|
70
|
+
}
|
|
71
|
+
function readCache() {
|
|
72
|
+
try {
|
|
73
|
+
const raw = fs.readFileSync(getCachePath(), "utf8");
|
|
74
|
+
const parsed = JSON.parse(raw);
|
|
75
|
+
if (typeof parsed.checkedAt !== "number")
|
|
76
|
+
return null;
|
|
77
|
+
return { checkedAt: parsed.checkedAt, latest: parsed.latest ?? null };
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function writeCache(latest) {
|
|
84
|
+
try {
|
|
85
|
+
const dir = getConfigDir();
|
|
86
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
87
|
+
fs.writeFileSync(getCachePath(), JSON.stringify({ checkedAt: Date.now(), latest }), { mode: 0o600 });
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
// best-effort; a missing cache just means we'll hit the registry again next launch
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Resolve whether a newer version is available, using a short-lived cache so
|
|
95
|
+
* we don't hammer the npm registry on every launch.
|
|
96
|
+
*/
|
|
97
|
+
export async function getUpdateInfo(pkg, current) {
|
|
98
|
+
const cached = readCache();
|
|
99
|
+
if (cached && Date.now() - cached.checkedAt < CACHE_TTL_MS) {
|
|
100
|
+
return makeResult(current, cached.latest);
|
|
101
|
+
}
|
|
102
|
+
const latest = await fetchLatestNpmVersion(pkg);
|
|
103
|
+
if (latest !== null)
|
|
104
|
+
writeCache(latest);
|
|
105
|
+
return makeResult(current, latest);
|
|
106
|
+
}
|
|
107
|
+
function makeResult(current, latest) {
|
|
108
|
+
if (latest === null) {
|
|
109
|
+
return { current, latest: null, updateAvailable: false, unknown: true };
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
current,
|
|
113
|
+
latest,
|
|
114
|
+
updateAvailable: compareVersions(current, latest) < 0,
|
|
115
|
+
unknown: false,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
/** Invalidate the cached "latest" so the next launch re-checks. */
|
|
119
|
+
export function clearUpdateCache() {
|
|
120
|
+
try {
|
|
121
|
+
fs.unlinkSync(getCachePath());
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
// ignore
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/** Run `npm install -g <pkg>@latest` and return the exit code (0 = success). */
|
|
128
|
+
export function runNpmUpdate(pkg) {
|
|
129
|
+
return new Promise((resolve) => {
|
|
130
|
+
// On Windows, npm is a .cmd shim; spawning it directly fails without shell:true.
|
|
131
|
+
const isWin = process.platform === "win32";
|
|
132
|
+
const child = spawn("npm", ["install", "-g", `${pkg}@latest`], {
|
|
133
|
+
stdio: "inherit",
|
|
134
|
+
shell: isWin,
|
|
135
|
+
});
|
|
136
|
+
child.on("error", () => resolve(1));
|
|
137
|
+
child.on("close", (code) => resolve(typeof code === "number" ? code : 1));
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Detect whether the running CLI is inside a global npm install, so the
|
|
142
|
+
* update command can surface a friendlier message for local/dev installs.
|
|
143
|
+
*/
|
|
144
|
+
export function isGlobalInstall() {
|
|
145
|
+
// When installed via `npm i -g @matterailab/orbcode`, the entrypoint lives
|
|
146
|
+
// somewhere like <global root>/node_modules/@matterailab/orbcode/...
|
|
147
|
+
const here = process.argv[1] || "";
|
|
148
|
+
return /node_modules[\\/]@matterailab[\\/]orbcode/.test(here);
|
|
149
|
+
}
|
|
150
|
+
/** Best-effort: run `npm root -g` so we can show the install location. */
|
|
151
|
+
export async function getGlobalInstallRoot() {
|
|
152
|
+
try {
|
|
153
|
+
const { stdout } = await execFileAsync("npm", ["root", "-g"], {
|
|
154
|
+
shell: process.platform === "win32",
|
|
155
|
+
});
|
|
156
|
+
const trimmed = stdout.trim();
|
|
157
|
+
return trimmed ? trimmed : null;
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@matterailab/orbcode",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"description": "OrbCode CLI — agentic coding in your terminal, powered by Axon models by MatterAI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"ink": "^5.2.1",
|
|
41
41
|
"open": "^10.1.0",
|
|
42
42
|
"openai": "^4.78.0",
|
|
43
|
-
"picomatch": "^4.0.
|
|
43
|
+
"picomatch": "^4.0.4",
|
|
44
44
|
"react": "^18.3.1"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|