@giovannijecha/jecode 0.1.8 → 0.2.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 +41 -27
- package/dist/account-lock.js +112 -0
- package/dist/accounts.js +100 -0
- package/dist/cli-info.js +2 -2
- package/dist/commands.js +27 -102
- package/dist/controller.js +3 -0
- package/dist/credential-commands.js +40 -14
- package/dist/credential-safety.js +2 -1
- package/dist/external-browser.js +53 -0
- package/dist/oauth-http.js +114 -0
- package/dist/openai-account-command.js +124 -0
- package/dist/openai-account.js +91 -0
- package/dist/openai-oauth-callback.js +186 -0
- package/dist/openai-oauth-tokens.js +65 -0
- package/dist/openai-oauth.js +203 -0
- package/dist/permission-command.js +107 -0
- package/dist/permissions.js +113 -0
- package/dist/provider-commands.js +22 -62
- package/dist/provider-label.js +10 -0
- package/dist/providers/anthropic.js +1 -1
- package/dist/providers/index.js +2 -1
- package/dist/providers/ollama.js +1 -1
- package/dist/providers/openai-codex.js +114 -0
- package/dist/providers/openai-stream.js +13 -9
- package/dist/providers/openai-wire.js +4 -4
- package/dist/providers/openai.js +2 -2
- package/dist/providers/sse.js +1 -1
- package/dist/settings-command.js +13 -6
- package/dist/tools/shell.js +1 -1
- package/dist/transcript.js +0 -3
- package/dist/tui/app-workflows.js +17 -17
- package/dist/tui/app.js +6 -11
- package/dist/tui/approve.js +6 -34
- package/dist/tui/blocks.js +1 -3
- package/dist/tui/complete.js +4 -4
- package/dist/tui/components/menu.js +4 -3
- package/dist/tui/components/misc.js +0 -6
- package/dist/tui/components/status.js +6 -7
- package/dist/tui/feedback.js +11 -9
- package/dist/tui/help.js +29 -0
- package/dist/tui/modal.js +22 -11
- package/dist/tui/overlay.js +15 -4
- package/dist/tui/picker.js +1 -1
- package/dist/tui/session-view.js +12 -6
- package/dist/version.js +14 -0
- package/docs/assets/brand/jeco-256.png +0 -0
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -3,11 +3,7 @@
|
|
|
3
3
|
</p>
|
|
4
4
|
|
|
5
5
|
<p align="center">
|
|
6
|
-
<
|
|
7
|
-
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/brand/wordmark-light.svg">
|
|
8
|
-
<source media="(prefers-color-scheme: light)" srcset="docs/assets/brand/wordmark-dark.svg">
|
|
9
|
-
<img src="docs/assets/brand/wordmark-dark.svg" width="280" alt="Jecode">
|
|
10
|
-
</picture>
|
|
6
|
+
<img src="docs/assets/brand/wordmark-steel.svg" width="280" alt="Jecode">
|
|
11
7
|
</p>
|
|
12
8
|
|
|
13
9
|
<p align="center"><strong>Your code. Your loop.</strong></p>
|
|
@@ -29,7 +25,7 @@
|
|
|
29
25
|
<a href="https://github.com/giovannijecha/jecode/releases">Releases</a>
|
|
30
26
|
</p>
|
|
31
27
|
|
|
32
|
-
> Jecode is an early 0.
|
|
28
|
+
> Jecode is an early 0.2.x release. The core loop is usable today; commands and
|
|
33
29
|
> terminal interactions may still evolve before 1.0.
|
|
34
30
|
|
|
35
31
|
## Why Jecode
|
|
@@ -40,8 +36,8 @@
|
|
|
40
36
|
diffs, approvals, reasoning, and status all share one full-screen TUI.
|
|
41
37
|
- **Permission-aware.** Reads stay transparent; dangerous actions ask first.
|
|
42
38
|
Session approvals can be reviewed and revoked.
|
|
43
|
-
- **Provider-neutral.** Use Anthropic
|
|
44
|
-
without changing the workflow.
|
|
39
|
+
- **Provider-neutral.** Use Anthropic or OpenAI API keys, a ChatGPT account, or
|
|
40
|
+
a local/remote Ollama server without changing the workflow.
|
|
45
41
|
- **Lean by construction.** Jecode installs as plain JavaScript, runs on
|
|
46
42
|
Node.js 22.18+ (22.x) or Node.js 24+, executes no installation scripts, and
|
|
47
43
|
has zero third-party runtime dependencies.
|
|
@@ -149,15 +145,27 @@ published package runs no compilation or installation scripts.
|
|
|
149
145
|
## First session
|
|
150
146
|
|
|
151
147
|
Jecode opens on an empty composer instead of forcing a setup wizard. Type
|
|
152
|
-
**/settings** when you are ready to choose a provider, select a model, and
|
|
153
|
-
|
|
154
|
-
saved explicitly under **~/.jecode**; it is never stored in the
|
|
148
|
+
**/settings** when you are ready to choose a provider, select a model, and
|
|
149
|
+
configure authentication. An API key can remain in memory for the current
|
|
150
|
+
session or be saved explicitly under **~/.jecode**; it is never stored in the
|
|
151
|
+
workspace.
|
|
155
152
|
|
|
156
|
-
| Provider |
|
|
153
|
+
| Provider ID | Authentication | Notes |
|
|
157
154
|
|---|---|---|
|
|
158
|
-
|
|
|
159
|
-
|
|
|
160
|
-
|
|
|
155
|
+
| anthropic | ANTHROPIC_API_KEY | Anthropic API |
|
|
156
|
+
| openai | OPENAI_API_KEY | OpenAI API |
|
|
157
|
+
| openai-codex | ChatGPT OAuth | Experimental; uses eligible ChatGPT Codex access |
|
|
158
|
+
| ollama | OLLAMA_API_KEY for Cloud/remote | Cloud with a key, local without one |
|
|
159
|
+
|
|
160
|
+
Choose **openai-codex** to sign in on OpenAI's website without pasting a key.
|
|
161
|
+
Jecode offers a local browser callback and a device-code flow; WSL and remote
|
|
162
|
+
terminals default to the device code. The connection is saved only after the
|
|
163
|
+
flow completes. Availability and usage limits are determined by the ChatGPT
|
|
164
|
+
account and plan, not by OpenAI API credits. This integration is experimental
|
|
165
|
+
and is not an endorsement of Jecode by OpenAI.
|
|
166
|
+
|
|
167
|
+
Anthropic remains API-key only. Jecode does not reuse a Claude consumer
|
|
168
|
+
subscription or copy credentials from another client.
|
|
161
169
|
|
|
162
170
|
Choose **cloud**, **local**, or a custom endpoint from the Ollama connection row
|
|
163
171
|
in **/settings**. Existing users with an Ollama API key automatically use
|
|
@@ -170,16 +178,15 @@ Type **/** to open searchable command completion inside the composer.
|
|
|
170
178
|
|
|
171
179
|
| Command | What it does |
|
|
172
180
|
|---|---|
|
|
173
|
-
| /settings | Manage provider, connection, model, limits, motion, and
|
|
181
|
+
| /settings | Manage provider, connection, model, limits, motion, and authentication |
|
|
174
182
|
| /effort | Change and save reasoning effort directly |
|
|
175
183
|
| /providers | Switch the provider for the next turn |
|
|
176
184
|
| /models | Search the live model catalogue |
|
|
177
|
-
| /credentials |
|
|
178
|
-
| /permissions |
|
|
179
|
-
| /
|
|
180
|
-
| /new | Start a clean in-memory conversation |
|
|
185
|
+
| /credentials | Manage API keys and the connected ChatGPT account |
|
|
186
|
+
| /permissions | Manage session tool access and remembered approvals |
|
|
187
|
+
| /new | Start a clean conversation and reset session tool permissions |
|
|
181
188
|
| /export | Save a timestamped Markdown transcript in the launch directory |
|
|
182
|
-
| /help |
|
|
189
|
+
| /help | Open a temporary keyboard reference in the composer dock |
|
|
183
190
|
| /exit | Restore the terminal and exit |
|
|
184
191
|
|
|
185
192
|
Useful controls:
|
|
@@ -196,7 +203,9 @@ Useful controls:
|
|
|
196
203
|
The one-line footer keeps model, effort, and workspace on the left. Live work
|
|
197
204
|
stays visible on the reasoning and tool rail; the right edge carries the
|
|
198
205
|
interrupt hint, readiness guidance, and temporary feedback without polluting
|
|
199
|
-
the transcript.
|
|
206
|
+
the transcript. Slash commands never append content to the conversation or its
|
|
207
|
+
Markdown export; **/help** closes with **Esc**, and token accounting remains
|
|
208
|
+
internal to the active session.
|
|
200
209
|
|
|
201
210
|
## Configuration
|
|
202
211
|
|
|
@@ -210,14 +219,16 @@ settings, built-in defaults.
|
|
|
210
219
|
| --ollama-host | OLLAMA_HOST | Cloud with an Ollama key, local without one |
|
|
211
220
|
| --root | — | Current directory |
|
|
212
221
|
| --effort | JECODE_EFFORT | high |
|
|
213
|
-
| --max-tokens | JECODE_MAX_TOKENS | 64000 |
|
|
222
|
+
| --max-tokens | JECODE_MAX_TOKENS | 64000; not sent by openai-codex |
|
|
214
223
|
| --max-steps | JECODE_MAX_STEPS | 40 |
|
|
215
224
|
| --reduced-motion | JECODE_REDUCED_MOTION=1 | Off |
|
|
216
225
|
| --auto-approve | JECODE_AUTO_APPROVE=1 | Off |
|
|
217
226
|
|
|
218
227
|
Persistent preferences live in **~/.jecode/settings.json**. Explicitly saved
|
|
219
|
-
|
|
220
|
-
|
|
228
|
+
API keys live in **~/.jecode/credentials.json**; the ChatGPT OAuth account lives
|
|
229
|
+
separately in **~/.jecode/accounts.json**. Both secret stores use owner-only
|
|
230
|
+
permissions where the operating system supports them. Environment API keys
|
|
231
|
+
always win.
|
|
221
232
|
|
|
222
233
|
Jecode has one interface theme: dark Steel. **NO_COLOR** is supported for
|
|
223
234
|
terminals and pipelines that disable colour.
|
|
@@ -241,12 +252,15 @@ untrusted data.
|
|
|
241
252
|
|
|
242
253
|
- Tool paths are confined to the selected workspace. Writes reject symlink and
|
|
243
254
|
junction components, then revalidate the boundary during atomic replacement.
|
|
244
|
-
- Dangerous tools
|
|
245
|
-
**--auto-approve**.
|
|
255
|
+
- Dangerous tools ask by default unless explicitly allowed for the session in
|
|
256
|
+
**/permissions** or the process started with **--auto-approve**.
|
|
246
257
|
- Credential fields are masked and excluded from transcripts. Approved shell
|
|
247
258
|
commands receive no credential-like environment variables, and recognized
|
|
248
259
|
credential values are redacted before tool output reaches the model, screen,
|
|
249
260
|
history, or export.
|
|
261
|
+
- ChatGPT OAuth uses PKCE and an exact loopback callback or the OpenAI device
|
|
262
|
+
flow. Refresh-token rotation is serialized across Jecode processes; OAuth
|
|
263
|
+
tokens are withheld and redacted like API keys.
|
|
250
264
|
- Terminal control characters are neutralized before rendering.
|
|
251
265
|
- Remote Ollama endpoints require HTTPS. Provider HTTP redirects are rejected
|
|
252
266
|
rather than followed across an implicit trust boundary.
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// A tiny cross-process lock for rotating OAuth credentials.
|
|
2
|
+
//
|
|
3
|
+
// Refresh tokens may rotate after one use. Two Jecode processes refreshing
|
|
4
|
+
// the same account concurrently would make one of them persist a dead token,
|
|
5
|
+
// so account mutations serialize through an atomic lock directory.
|
|
6
|
+
import { mkdir, open, readFile, rename, rmdir, stat, unlink } from "node:fs/promises";
|
|
7
|
+
import { randomUUID } from "node:crypto";
|
|
8
|
+
import * as path from "node:path";
|
|
9
|
+
const WAIT_MS = 50;
|
|
10
|
+
const WAIT_LIMIT_MS = 20_000;
|
|
11
|
+
const STALE_MS = 60_000;
|
|
12
|
+
export async function withAccountLock(accountFile, body, signal) {
|
|
13
|
+
throwIfAborted(signal);
|
|
14
|
+
const directory = `${accountFile}.lock`;
|
|
15
|
+
const token = `${process.pid}:${randomUUID()}`;
|
|
16
|
+
const started = Date.now();
|
|
17
|
+
while (!(await acquire(directory, token))) {
|
|
18
|
+
if (signal?.aborted === true)
|
|
19
|
+
throw abortReason(signal);
|
|
20
|
+
if (Date.now() - started >= WAIT_LIMIT_MS) {
|
|
21
|
+
throw new Error("timed out waiting for the account store");
|
|
22
|
+
}
|
|
23
|
+
await recoverStale(directory);
|
|
24
|
+
await wait(WAIT_MS, signal);
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
throwIfAborted(signal);
|
|
28
|
+
return await body();
|
|
29
|
+
}
|
|
30
|
+
finally {
|
|
31
|
+
await release(directory, token);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
async function acquire(directory, token) {
|
|
35
|
+
let created = false;
|
|
36
|
+
try {
|
|
37
|
+
await mkdir(directory, { mode: 0o700 });
|
|
38
|
+
created = true;
|
|
39
|
+
const owner = await open(path.join(directory, "owner"), "wx", 0o600);
|
|
40
|
+
try {
|
|
41
|
+
await owner.writeFile(token, "utf8");
|
|
42
|
+
await owner.sync();
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
await owner.close();
|
|
46
|
+
}
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
if (!created && error.code === "EEXIST")
|
|
51
|
+
return false;
|
|
52
|
+
if (created)
|
|
53
|
+
await removeLock(directory);
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
async function recoverStale(directory) {
|
|
58
|
+
try {
|
|
59
|
+
const details = await stat(directory);
|
|
60
|
+
if (Date.now() - details.mtimeMs < STALE_MS)
|
|
61
|
+
return;
|
|
62
|
+
const quarantined = `${directory}.${randomUUID()}.stale`;
|
|
63
|
+
await rename(directory, quarantined);
|
|
64
|
+
await unlink(path.join(quarantined, "owner")).catch(() => undefined);
|
|
65
|
+
await rmdir(quarantined).catch(() => undefined);
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
const code = error.code;
|
|
69
|
+
if (code !== "ENOENT" && code !== "EACCES" && code !== "EPERM")
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
async function release(directory, token) {
|
|
74
|
+
try {
|
|
75
|
+
const owner = path.join(directory, "owner");
|
|
76
|
+
if ((await readFile(owner, "utf8")) !== token)
|
|
77
|
+
return;
|
|
78
|
+
await unlink(owner);
|
|
79
|
+
await rmdir(directory);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// A recovered stale lock or an already-removed directory is no longer ours.
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
async function removeLock(directory) {
|
|
86
|
+
await unlink(path.join(directory, "owner")).catch(() => undefined);
|
|
87
|
+
await rmdir(directory).catch(() => undefined);
|
|
88
|
+
}
|
|
89
|
+
function wait(ms, signal) {
|
|
90
|
+
return new Promise((resolve, reject) => {
|
|
91
|
+
if (signal?.aborted === true) {
|
|
92
|
+
reject(abortReason(signal));
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const timer = setTimeout(() => {
|
|
96
|
+
signal?.removeEventListener("abort", onAbort);
|
|
97
|
+
resolve();
|
|
98
|
+
}, ms);
|
|
99
|
+
const onAbort = () => {
|
|
100
|
+
clearTimeout(timer);
|
|
101
|
+
reject(signal === undefined ? new Error("cancelled") : abortReason(signal));
|
|
102
|
+
};
|
|
103
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
function abortReason(signal) {
|
|
107
|
+
return signal.reason instanceof Error ? signal.reason : new Error("cancelled");
|
|
108
|
+
}
|
|
109
|
+
function throwIfAborted(signal) {
|
|
110
|
+
if (signal?.aborted === true)
|
|
111
|
+
throw abortReason(signal);
|
|
112
|
+
}
|
package/dist/accounts.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// OAuth accounts persisted under ~/.jecode, apart from API keys.
|
|
2
|
+
import { chmod, mkdir } from "node:fs/promises";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { atomicWrite } from "./atomic.js";
|
|
6
|
+
import { withAccountLock } from "./account-lock.js";
|
|
7
|
+
import { userDataLabel, userDataPath } from "./user-data.js";
|
|
8
|
+
let cached;
|
|
9
|
+
export function openAICodexAccount() {
|
|
10
|
+
const account = store().accounts["openai-codex"];
|
|
11
|
+
return account === undefined ? undefined : { ...account };
|
|
12
|
+
}
|
|
13
|
+
export function accountValues() {
|
|
14
|
+
const account = store().accounts["openai-codex"];
|
|
15
|
+
return account === undefined ? [] : [account.accessToken, account.refreshToken];
|
|
16
|
+
}
|
|
17
|
+
export function accountsPath() {
|
|
18
|
+
return userDataPath("accounts.json");
|
|
19
|
+
}
|
|
20
|
+
export function accountsLabel() {
|
|
21
|
+
return userDataLabel("accounts.json");
|
|
22
|
+
}
|
|
23
|
+
export async function updateOpenAICodexAccount(change, signal) {
|
|
24
|
+
const file = accountsPath();
|
|
25
|
+
const directory = path.dirname(file);
|
|
26
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
27
|
+
if (process.platform !== "win32")
|
|
28
|
+
await chmod(directory, 0o700);
|
|
29
|
+
return withAccountLock(file, async () => {
|
|
30
|
+
const current = readStore(file);
|
|
31
|
+
const next = await change(current.accounts["openai-codex"]);
|
|
32
|
+
const accounts = { ...current.accounts };
|
|
33
|
+
if (next === undefined)
|
|
34
|
+
delete accounts["openai-codex"];
|
|
35
|
+
else
|
|
36
|
+
accounts["openai-codex"] = { ...next };
|
|
37
|
+
const updated = { version: 1, accounts };
|
|
38
|
+
await atomicWrite(file, `${JSON.stringify(updated, null, 2)}\n`, { mode: 0o600 });
|
|
39
|
+
cached = updated;
|
|
40
|
+
return next === undefined ? undefined : { ...next };
|
|
41
|
+
}, signal);
|
|
42
|
+
}
|
|
43
|
+
export function reloadAccounts() {
|
|
44
|
+
cached = undefined;
|
|
45
|
+
}
|
|
46
|
+
function store() {
|
|
47
|
+
if (cached === undefined)
|
|
48
|
+
cached = readStore(accountsPath());
|
|
49
|
+
return cached;
|
|
50
|
+
}
|
|
51
|
+
function readStore(file) {
|
|
52
|
+
try {
|
|
53
|
+
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
54
|
+
return normalize(parsed);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return { version: 1, accounts: {} };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function normalize(value) {
|
|
61
|
+
if (!record(value) || value["version"] !== 1 || !record(value["accounts"])) {
|
|
62
|
+
return { version: 1, accounts: {} };
|
|
63
|
+
}
|
|
64
|
+
const account = normalizeOpenAI(value["accounts"]["openai-codex"]);
|
|
65
|
+
return {
|
|
66
|
+
version: 1,
|
|
67
|
+
accounts: account === undefined ? {} : { "openai-codex": account },
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
function normalizeOpenAI(value) {
|
|
71
|
+
if (!record(value))
|
|
72
|
+
return undefined;
|
|
73
|
+
const accessToken = nonempty(value["accessToken"]);
|
|
74
|
+
const refreshToken = nonempty(value["refreshToken"]);
|
|
75
|
+
const accountId = nonempty(value["accountId"]);
|
|
76
|
+
const expiresAt = value["expiresAt"];
|
|
77
|
+
if (accessToken === undefined ||
|
|
78
|
+
refreshToken === undefined ||
|
|
79
|
+
accountId === undefined ||
|
|
80
|
+
typeof expiresAt !== "number" ||
|
|
81
|
+
!Number.isSafeInteger(expiresAt) ||
|
|
82
|
+
expiresAt <= 0)
|
|
83
|
+
return undefined;
|
|
84
|
+
const email = nonempty(value["email"]);
|
|
85
|
+
const plan = nonempty(value["plan"]);
|
|
86
|
+
return {
|
|
87
|
+
accessToken,
|
|
88
|
+
refreshToken,
|
|
89
|
+
expiresAt,
|
|
90
|
+
accountId,
|
|
91
|
+
...(email === undefined ? {} : { email }),
|
|
92
|
+
...(plan === undefined ? {} : { plan }),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
function nonempty(value) {
|
|
96
|
+
return typeof value === "string" && value.trim() !== "" ? value : undefined;
|
|
97
|
+
}
|
|
98
|
+
function record(value) {
|
|
99
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
100
|
+
}
|
package/dist/cli-info.js
CHANGED
|
@@ -8,10 +8,10 @@ Usage:
|
|
|
8
8
|
|
|
9
9
|
Options:
|
|
10
10
|
--root <path> workspace root (default: current directory)
|
|
11
|
-
--provider <id> anthropic, openai, or ollama
|
|
11
|
+
--provider <id> anthropic, openai, openai-codex, or ollama
|
|
12
12
|
--model <id> model for the selected provider
|
|
13
13
|
--ollama-host <url> Ollama Cloud, local, or custom endpoint
|
|
14
|
-
--effort <level> low, medium, high, or max
|
|
14
|
+
--effort <level> low, medium, high, xhigh, or max
|
|
15
15
|
--max-tokens <number> output-token ceiling
|
|
16
16
|
--max-steps <number> tool-loop ceiling
|
|
17
17
|
--reduced-motion disable animated terminal states
|
package/dist/commands.js
CHANGED
|
@@ -4,33 +4,27 @@
|
|
|
4
4
|
// without asking the provider what it has — but none of them ever sends a
|
|
5
5
|
// message. Provider and credential interaction lives in provider-commands.ts;
|
|
6
6
|
// this file keeps command discovery, dispatch, and local session operations.
|
|
7
|
-
import {
|
|
8
|
-
import { modelsCommand, providersCommand, setupCommand } from "./provider-commands.js";
|
|
7
|
+
import { modelsCommand, providersCommand } from "./provider-commands.js";
|
|
9
8
|
import { credentialsCommand } from "./credential-commands.js";
|
|
9
|
+
import { permissionsCommand } from "./permission-command.js";
|
|
10
10
|
import { effortCommand, settingsCommand } from "./settings-command.js";
|
|
11
|
-
import { emptyUsage
|
|
11
|
+
import { emptyUsage } from "./usage.js";
|
|
12
12
|
/**
|
|
13
13
|
* The commands, declared once.
|
|
14
14
|
*
|
|
15
|
-
* `/help` and the completion menu both read this list, so a command cannot
|
|
16
|
-
* exist in the switch below and be missing from the two places that tell the
|
|
17
|
-
* user it exists.
|
|
18
|
-
*
|
|
19
15
|
* Deliberately short. `/settings` owns persistent defaults; the narrower
|
|
20
|
-
* provider, model, effort,
|
|
21
|
-
*
|
|
16
|
+
* provider, model, effort, and credential commands remain useful direct paths
|
|
17
|
+
* into the same interactions.
|
|
22
18
|
*/
|
|
23
19
|
export const COMMANDS = [
|
|
24
|
-
{ name: "help", blurb: "
|
|
20
|
+
{ name: "help", blurb: "show keyboard controls" },
|
|
25
21
|
{ name: "exit", blurb: "exit and restore the terminal" },
|
|
26
|
-
{ name: "new", blurb: "start
|
|
27
|
-
{ name: "usage", blurb: "show token usage for this session" },
|
|
22
|
+
{ name: "new", blurb: "start clean and reset tool permissions" },
|
|
28
23
|
{ name: "export", blurb: "save this transcript as Markdown" },
|
|
29
|
-
{ name: "permissions", blurb: "
|
|
24
|
+
{ name: "permissions", blurb: "manage session tool access" },
|
|
30
25
|
{ name: "settings", blurb: "change and save jecode defaults" },
|
|
31
26
|
{ name: "effort", blurb: "set the reasoning effort" },
|
|
32
|
-
{ name: "credentials", blurb: "
|
|
33
|
-
{ name: "setup", blurb: "make the current provider ready" },
|
|
27
|
+
{ name: "credentials", blurb: "manage API keys and connected accounts" },
|
|
34
28
|
{ name: "models", blurb: "pick a model, from what the provider offers" },
|
|
35
29
|
{ name: "providers", blurb: "pick a provider" },
|
|
36
30
|
];
|
|
@@ -38,7 +32,16 @@ export async function handleCommand(line, session, host) {
|
|
|
38
32
|
const [name] = line.slice(1).trim().split(/\s+/);
|
|
39
33
|
switch (name) {
|
|
40
34
|
case "help":
|
|
41
|
-
host.
|
|
35
|
+
if (host.showHelp === undefined) {
|
|
36
|
+
host.emit({
|
|
37
|
+
kind: "notice",
|
|
38
|
+
text: "interactive help needs the TUI · run jecode --help for startup options",
|
|
39
|
+
tone: "info",
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
await host.showHelp();
|
|
44
|
+
}
|
|
42
45
|
return "handled";
|
|
43
46
|
case "exit":
|
|
44
47
|
return "exit";
|
|
@@ -46,10 +49,7 @@ export async function handleCommand(line, session, host) {
|
|
|
46
49
|
session.history.length = 0;
|
|
47
50
|
session.usage = emptyUsage();
|
|
48
51
|
host.reset?.();
|
|
49
|
-
host.emit({ kind: "notice", text: "new session
|
|
50
|
-
return "handled";
|
|
51
|
-
case "usage":
|
|
52
|
-
host.emit(usage(session));
|
|
52
|
+
host.emit({ kind: "notice", text: "new session", tone: "info" });
|
|
53
53
|
return "handled";
|
|
54
54
|
case "export":
|
|
55
55
|
if (host.exportTranscript === undefined) {
|
|
@@ -57,11 +57,11 @@ export async function handleCommand(line, session, host) {
|
|
|
57
57
|
}
|
|
58
58
|
else {
|
|
59
59
|
const saved = await host.exportTranscript();
|
|
60
|
-
host.emit({ kind: "notice", text: `
|
|
60
|
+
host.emit({ kind: "notice", text: `saved · ${saved}`, tone: "info" });
|
|
61
61
|
}
|
|
62
62
|
return "handled";
|
|
63
63
|
case "permissions":
|
|
64
|
-
await
|
|
64
|
+
await permissionsCommand(session, host);
|
|
65
65
|
return "handled";
|
|
66
66
|
case "settings":
|
|
67
67
|
await settingsCommand(session, host);
|
|
@@ -72,9 +72,6 @@ export async function handleCommand(line, session, host) {
|
|
|
72
72
|
case "credentials":
|
|
73
73
|
await credentialsCommand(session, host);
|
|
74
74
|
return "handled";
|
|
75
|
-
case "setup":
|
|
76
|
-
await setupCommand(session, host);
|
|
77
|
-
return "handled";
|
|
78
75
|
case "models":
|
|
79
76
|
await modelsCommand(session, host);
|
|
80
77
|
return "handled";
|
|
@@ -82,89 +79,17 @@ export async function handleCommand(line, session, host) {
|
|
|
82
79
|
await providersCommand(session, host);
|
|
83
80
|
return "handled";
|
|
84
81
|
default:
|
|
85
|
-
host.emit({
|
|
82
|
+
host.emit({
|
|
83
|
+
kind: "notice",
|
|
84
|
+
text: `unknown command /${name ?? ""} · type / to browse commands`,
|
|
85
|
+
tone: "warn",
|
|
86
|
+
});
|
|
86
87
|
return "handled";
|
|
87
88
|
}
|
|
88
89
|
}
|
|
89
|
-
function usage(session) {
|
|
90
|
-
const value = session.usage;
|
|
91
|
-
const rows = [
|
|
92
|
-
` requests${String(value.requests).padStart(18)}`,
|
|
93
|
-
` latest context${formatTokens(value.lastInputTokens).padStart(12)}`,
|
|
94
|
-
` input${formatTokens(value.inputTokens).padStart(21)}`,
|
|
95
|
-
` output${formatTokens(value.outputTokens).padStart(20)}`,
|
|
96
|
-
];
|
|
97
|
-
if (value.cachedInputTokens > 0)
|
|
98
|
-
rows.push(` cached input${formatTokens(value.cachedInputTokens).padStart(14)}`);
|
|
99
|
-
if (value.cacheWriteInputTokens > 0) {
|
|
100
|
-
rows.push(` cache writes${formatTokens(value.cacheWriteInputTokens).padStart(14)}`);
|
|
101
|
-
}
|
|
102
|
-
if (value.reasoningTokens > 0)
|
|
103
|
-
rows.push(` reasoning${formatTokens(value.reasoningTokens).padStart(17)}`);
|
|
104
|
-
return { kind: "list", items: rows.map((text) => ({ text, dim: false })) };
|
|
105
|
-
}
|
|
106
|
-
async function permissions(session, host) {
|
|
107
|
-
const choose = chooser(host);
|
|
108
|
-
if (choose === undefined)
|
|
109
|
-
return;
|
|
110
|
-
if (session.config.autoApprove) {
|
|
111
|
-
host.emit({
|
|
112
|
-
kind: "notice",
|
|
113
|
-
text: "--auto-approve is active · every dangerous call is allowed for this process",
|
|
114
|
-
tone: "warn",
|
|
115
|
-
});
|
|
116
|
-
}
|
|
117
|
-
const entries = host.permissions?.() ?? [];
|
|
118
|
-
if (entries.length === 0) {
|
|
119
|
-
host.emit({ kind: "notice", text: "no remembered session permissions", tone: "info" });
|
|
120
|
-
return;
|
|
121
|
-
}
|
|
122
|
-
const index = await choose({
|
|
123
|
-
title: heading("revoke permission", "applies only to this session", session.palette),
|
|
124
|
-
options: [
|
|
125
|
-
...entries.map((entry) => ({ label: entry.label, hint: "revoke" })),
|
|
126
|
-
{ label: "all remembered permissions", hint: "revoke all" },
|
|
127
|
-
],
|
|
128
|
-
index: 0,
|
|
129
|
-
});
|
|
130
|
-
if (index === undefined)
|
|
131
|
-
return;
|
|
132
|
-
if (index === entries.length) {
|
|
133
|
-
host.revokePermission?.();
|
|
134
|
-
host.emit({ kind: "notice", text: "all remembered permissions revoked", tone: "info" });
|
|
135
|
-
return;
|
|
136
|
-
}
|
|
137
|
-
const entry = entries[index];
|
|
138
|
-
if (entry === undefined)
|
|
139
|
-
return;
|
|
140
|
-
host.revokePermission?.(entry.key);
|
|
141
|
-
host.emit({ kind: "notice", text: `revoked · ${entry.label}`, tone: "info" });
|
|
142
|
-
}
|
|
143
90
|
function chooser(host) {
|
|
144
91
|
if (host.choose === undefined) {
|
|
145
92
|
host.emit({ kind: "notice", text: "that command needs the screen", tone: "warn" });
|
|
146
93
|
}
|
|
147
94
|
return host.choose;
|
|
148
95
|
}
|
|
149
|
-
function help() {
|
|
150
|
-
const width = Math.max(...COMMANDS.map((c) => label(c).length));
|
|
151
|
-
return {
|
|
152
|
-
kind: "list",
|
|
153
|
-
items: [
|
|
154
|
-
...COMMANDS.map((command) => ({
|
|
155
|
-
text: ` ${label(command).padEnd(width + 4)}${command.blurb}`,
|
|
156
|
-
dim: false,
|
|
157
|
-
})),
|
|
158
|
-
{ text: "", dim: true },
|
|
159
|
-
{ text: " esc interrupts a running turn · ctrl+c exits", dim: true },
|
|
160
|
-
{ text: " wheel or pgup / pgdn scrolls · ctrl+l redraws", dim: true },
|
|
161
|
-
{ text: " ctrl+o expands the latest reasoning or tool details", dim: true },
|
|
162
|
-
{ text: " type in the model picker to filter · home/end jumps", dim: true },
|
|
163
|
-
{ text: " ↑↓ selects an open menu · tab completes · esc closes it", dim: true },
|
|
164
|
-
{ text: " enter selects or sends · alt+enter starts a new line", dim: true },
|
|
165
|
-
],
|
|
166
|
-
};
|
|
167
|
-
}
|
|
168
|
-
function label(command) {
|
|
169
|
-
return `/${command.name}`;
|
|
170
|
-
}
|
package/dist/controller.js
CHANGED
|
@@ -29,6 +29,9 @@ export async function runTurn(history, options, events, signal) {
|
|
|
29
29
|
onStatus: (status) => events.onStatus?.(status),
|
|
30
30
|
});
|
|
31
31
|
const calls = assistant.content.filter(isToolCall);
|
|
32
|
+
if (assistant.content.length === 0) {
|
|
33
|
+
throw new Error(`${options.provider.id} completed without an answer or tool call`);
|
|
34
|
+
}
|
|
32
35
|
if (calls.length > MAX_TOOL_CALLS_PER_STEP) {
|
|
33
36
|
throw new Error(`provider returned ${calls.length} tool calls in one step (maximum ${MAX_TOOL_CALLS_PER_STEP})`);
|
|
34
37
|
}
|