@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.
Files changed (47) hide show
  1. package/README.md +41 -27
  2. package/dist/account-lock.js +112 -0
  3. package/dist/accounts.js +100 -0
  4. package/dist/cli-info.js +2 -2
  5. package/dist/commands.js +27 -102
  6. package/dist/controller.js +3 -0
  7. package/dist/credential-commands.js +40 -14
  8. package/dist/credential-safety.js +2 -1
  9. package/dist/external-browser.js +53 -0
  10. package/dist/oauth-http.js +114 -0
  11. package/dist/openai-account-command.js +124 -0
  12. package/dist/openai-account.js +91 -0
  13. package/dist/openai-oauth-callback.js +186 -0
  14. package/dist/openai-oauth-tokens.js +65 -0
  15. package/dist/openai-oauth.js +203 -0
  16. package/dist/permission-command.js +107 -0
  17. package/dist/permissions.js +113 -0
  18. package/dist/provider-commands.js +22 -62
  19. package/dist/provider-label.js +10 -0
  20. package/dist/providers/anthropic.js +1 -1
  21. package/dist/providers/index.js +2 -1
  22. package/dist/providers/ollama.js +1 -1
  23. package/dist/providers/openai-codex.js +114 -0
  24. package/dist/providers/openai-stream.js +13 -9
  25. package/dist/providers/openai-wire.js +4 -4
  26. package/dist/providers/openai.js +2 -2
  27. package/dist/providers/sse.js +1 -1
  28. package/dist/settings-command.js +13 -6
  29. package/dist/tools/shell.js +1 -1
  30. package/dist/transcript.js +0 -3
  31. package/dist/tui/app-workflows.js +17 -17
  32. package/dist/tui/app.js +6 -11
  33. package/dist/tui/approve.js +6 -34
  34. package/dist/tui/blocks.js +1 -3
  35. package/dist/tui/complete.js +4 -4
  36. package/dist/tui/components/menu.js +4 -3
  37. package/dist/tui/components/misc.js +0 -6
  38. package/dist/tui/components/status.js +6 -7
  39. package/dist/tui/feedback.js +11 -9
  40. package/dist/tui/help.js +29 -0
  41. package/dist/tui/modal.js +22 -11
  42. package/dist/tui/overlay.js +15 -4
  43. package/dist/tui/picker.js +1 -1
  44. package/dist/tui/session-view.js +12 -6
  45. package/dist/version.js +14 -0
  46. package/docs/assets/brand/jeco-256.png +0 -0
  47. package/package.json +5 -1
package/README.md CHANGED
@@ -3,11 +3,7 @@
3
3
  </p>
4
4
 
5
5
  <p align="center">
6
- <picture>
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.1.x release. The core loop is usable today; commands and
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, OpenAI, or a local/remote Ollama server
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 add a
153
- credential. A credential can remain in memory for the current session or be
154
- saved explicitly under **~/.jecode**; it is never stored in the workspace.
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 | Credential | Notes |
153
+ | Provider ID | Authentication | Notes |
157
154
  |---|---|---|
158
- | Anthropic | ANTHROPIC_API_KEY | Cloud |
159
- | OpenAI | OPENAI_API_KEY | Cloud |
160
- | Ollama | OLLAMA_API_KEY for Cloud/remote | Cloud with a key, local without one |
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 credentials |
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 | Add, replace, inspect, or forget saved credentials |
178
- | /permissions | Review or revoke remembered session approvals |
179
- | /usage | Show normalized token usage |
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 | Show commands and controls |
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
- credentials live in **~/.jecode/credentials.json** with owner-only permissions
220
- where the operating system supports them. Environment credentials always win.
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 require approval unless the process was started with
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
+ }
@@ -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 { heading } from "./tui/picker.js";
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, formatTokens } from "./usage.js";
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, credential, and setup commands remain useful direct
21
- * paths into the same interactions.
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: "this list" },
20
+ { name: "help", blurb: "show keyboard controls" },
25
21
  { name: "exit", blurb: "exit and restore the terminal" },
26
- { name: "new", blurb: "start a clean in-memory session" },
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: "review or revoke remembered approvals" },
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: "inspect, replace, or forget API keys" },
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.emit(help());
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 · history, usage, and approvals cleared", tone: "info" });
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: `transcript saved to ${saved}`, tone: "info" });
60
+ host.emit({ kind: "notice", text: `saved · ${saved}`, tone: "info" });
61
61
  }
62
62
  return "handled";
63
63
  case "permissions":
64
- await permissions(session, host);
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({ kind: "notice", text: `unknown command /${name ?? ""} — try /help`, tone: "warn" });
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
- }
@@ -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
  }