@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
@@ -1,16 +1,17 @@
1
- // Provider, model, and setup command flows.
1
+ // Provider and model command flows.
2
2
  import { heading } from "./tui/picker.js";
3
3
  import { PROVIDERS } from "./providers/index.js";
4
4
  import { readSettings } from "./settings.js";
5
- import { askForKey } from "./credential-commands.js";
5
+ import { authenticationNeed, ensureProviderAuthentication, } from "./credential-commands.js";
6
6
  import { providerFailure } from "./provider-errors.js";
7
+ import { providerLabel } from "./provider-label.js";
7
8
  /**
8
9
  * The provider menu.
9
10
  *
10
11
  * Every provider is offered, including the ones that cannot run: the reason
11
12
  * one is unusable — the variable it wants, by name — is worth more on screen
12
13
  * than the row would be worth hidden. A blocked choice opens the same masked
13
- * credential flow used by setup and settings, and cancellation leaves the old choice.
14
+ * credential flow used by settings, and cancellation leaves the old choice.
14
15
  */
15
16
  export async function providersCommand(session, host, behavior = {}) {
16
17
  const choose = chooser(host);
@@ -34,32 +35,22 @@ export async function providersCommand(session, host, behavior = {}) {
34
35
  // Picking the provider already in use is not a no-op when it cannot run:
35
36
  // it is how the user asks to fix the reason it cannot.
36
37
  if (chosen.id === session.provider.id) {
37
- const blocked = chosen.blocked();
38
- if (blocked !== undefined) {
39
- if (!isCredentialBlocker(chosen, blocked)) {
40
- host.emit({ kind: "notice", text: blocked, tone: "error" });
41
- return false;
42
- }
43
- await askForKey(chosen.keyVar, host, session.palette);
44
- if (chosen.blocked() !== undefined)
45
- return false;
46
- }
47
- return true;
38
+ if (!(await ensureProviderAuthentication(chosen, session, host)))
39
+ return false;
40
+ return session.model === ""
41
+ ? modelsCommand(session, host, { announce: false, save: behavior.save })
42
+ : true;
48
43
  }
49
44
  // A provider that cannot run is worth one offer to fix it, here, rather
50
45
  // than a note telling the user to leave and export something.
51
46
  const blocked = chosen.blocked();
52
47
  if (blocked !== undefined) {
53
- if (!isCredentialBlocker(chosen, blocked)) {
54
- host.emit({ kind: "notice", text: blocked, tone: "error" });
55
- return false;
56
- }
57
- await askForKey(chosen.keyVar, host, session.palette);
48
+ await ensureProviderAuthentication(chosen, session, host);
58
49
  const still = chosen.blocked();
59
50
  if (still !== undefined) {
60
51
  host.emit({
61
52
  kind: "notice",
62
- text: `${providerName(chosen.id)} still needs an API key · provider unchanged`,
53
+ text: `${providerLabel(chosen.id)} still needs ${authenticationNeed(chosen)} · provider unchanged`,
63
54
  tone: "warn",
64
55
  });
65
56
  return false;
@@ -77,6 +68,13 @@ export async function providersCommand(session, host, behavior = {}) {
77
68
  session.model = readSettings().models?.[chosen.id] ?? chosen.defaultModel;
78
69
  session.config.providerId = chosen.id;
79
70
  session.config.model = session.model;
71
+ if (session.model === "" && !(await modelsCommand(session, host, { announce: false, save: false }))) {
72
+ session.provider = before.provider;
73
+ session.model = before.model;
74
+ session.config.providerId = before.providerId;
75
+ session.config.model = before.configModel;
76
+ return false;
77
+ }
80
78
  if (behavior.save !== false) {
81
79
  const saved = readSettings();
82
80
  const models = { ...saved.models };
@@ -93,7 +91,7 @@ export async function providersCommand(session, host, behavior = {}) {
93
91
  if (behavior.announce !== false) {
94
92
  host.emit({
95
93
  kind: "notice",
96
- text: session.model === "" ? `${chosen.id} pick a model with /models` : `${chosen.id} · ${session.model}`,
94
+ text: session.model === "" ? `provider · ${chosen.id} · pick a model` : `provider · ${chosen.id}`,
97
95
  tone: "info",
98
96
  });
99
97
  }
@@ -111,16 +109,12 @@ export async function modelsCommand(session, host, behavior = {}) {
111
109
  // to pick a model, and "go and export a variable" is not an answer.
112
110
  const blocked = provider.blocked();
113
111
  if (blocked !== undefined) {
114
- if (!isCredentialBlocker(provider, blocked)) {
115
- host.emit({ kind: "notice", text: blocked, tone: "error" });
116
- return false;
117
- }
118
- await askForKey(provider.keyVar, host, session.palette);
112
+ await ensureProviderAuthentication(provider, session, host);
119
113
  const still = provider.blocked();
120
114
  if (still !== undefined) {
121
115
  host.emit({
122
116
  kind: "notice",
123
- text: `${providerName(provider.id)} still needs an API key`,
117
+ text: `${providerLabel(provider.id)} still needs ${authenticationNeed(provider)}`,
124
118
  tone: "warn",
125
119
  });
126
120
  return false;
@@ -171,38 +165,10 @@ export async function modelsCommand(session, host, behavior = {}) {
171
165
  }
172
166
  }
173
167
  if (behavior.announce !== false) {
174
- host.emit({ kind: "notice", text: `${provider.id} · ${chosen}`, tone: "info" });
168
+ host.emit({ kind: "notice", text: `model · ${chosen}`, tone: "info" });
175
169
  }
176
170
  return true;
177
171
  }
178
- /** Make the provider selected by flags/environment usable without leaving the TUI. */
179
- export async function setupCommand(session, host) {
180
- const blocked = session.provider.blocked();
181
- if (blocked !== undefined) {
182
- if (!isCredentialBlocker(session.provider, blocked)) {
183
- host.emit({ kind: "notice", text: blocked, tone: "error" });
184
- return;
185
- }
186
- const accepted = await askForKey(session.provider.keyVar, host, session.palette);
187
- if (!accepted || session.provider.blocked() !== undefined) {
188
- host.emit({
189
- kind: "notice",
190
- text: `${providerName(session.provider.id)} still needs an API key · /setup`,
191
- tone: "warn",
192
- });
193
- return;
194
- }
195
- }
196
- if (session.model === "") {
197
- await modelsCommand(session, host);
198
- return;
199
- }
200
- host.emit({
201
- kind: "notice",
202
- text: `${session.provider.id} · ${session.model} · ${session.provider.location?.() ?? "cloud"} · ready`,
203
- tone: "info",
204
- });
205
- }
206
172
  /** The way to put a menu up, or nothing — and the reason, already said. */
207
173
  function chooser(host) {
208
174
  if (host.choose === undefined) {
@@ -210,12 +176,6 @@ function chooser(host) {
210
176
  }
211
177
  return host.choose;
212
178
  }
213
- function providerName(id) {
214
- return id === "" ? "Provider" : `${id[0]?.toUpperCase() ?? ""}${id.slice(1)}`;
215
- }
216
- function isCredentialBlocker(provider, blocked) {
217
- return blocked.startsWith(`${provider.keyVar} `);
218
- }
219
179
  async function saveDefaults(host, patch) {
220
180
  if (host.saveSettings === undefined)
221
181
  return true;
@@ -0,0 +1,10 @@
1
+ // Stable human-facing names for provider identifiers.
2
+ export function providerLabel(id) {
3
+ switch (id) {
4
+ case "anthropic": return "Anthropic";
5
+ case "openai": return "OpenAI";
6
+ case "openai-codex": return "OpenAI Codex";
7
+ case "ollama": return "Ollama";
8
+ default: return id === "" ? "Provider" : `${id[0]?.toUpperCase() ?? ""}${id.slice(1)}`;
9
+ }
10
+ }
@@ -27,7 +27,7 @@ export const anthropic = {
27
27
  // Sonnet is the default because it is the one that can be left running.
28
28
  // Opus via `--model claude-opus-5`, Haiku via `--model claude-haiku-4-5`.
29
29
  defaultModel: "claude-sonnet-5",
30
- keyVar: KEY,
30
+ auth: { kind: "api-key", keyVar: KEY },
31
31
  blocked() {
32
32
  return apiKey() === undefined ? `${KEY} is not set` : undefined;
33
33
  },
@@ -1,7 +1,8 @@
1
1
  import { anthropic } from "./anthropic.js";
2
2
  import { openai } from "./openai.js";
3
+ import { openaiCodex } from "./openai-codex.js";
3
4
  import { configureOllama, ollama } from "./ollama.js";
4
- export const PROVIDERS = [anthropic, openai, ollama];
5
+ export const PROVIDERS = [anthropic, openai, openaiCodex, ollama];
5
6
  export function providerNames() {
6
7
  return PROVIDERS.map((provider) => provider.id);
7
8
  }
@@ -25,7 +25,7 @@ export function ollamaConnection() {
25
25
  export const ollama = {
26
26
  id: "ollama",
27
27
  defaultModel: "",
28
- keyVar: KEY,
28
+ auth: { kind: "api-key", keyVar: KEY },
29
29
  // The only provider whose key is conditional: a daemon on this machine is
30
30
  // reached over loopback and asks for nothing, so demanding a key there
31
31
  // would be an invented requirement.
@@ -0,0 +1,114 @@
1
+ // ChatGPT-backed Codex Responses, kept separate from the OpenAI API provider.
2
+ import { randomUUID } from "node:crypto";
3
+ import { openAICodexAccount } from "../accounts.js";
4
+ import { openAIAuthorization } from "../openai-account.js";
5
+ import { applicationVersion } from "../version.js";
6
+ import { getJson, postSse } from "./http.js";
7
+ import { assembleOpenAI } from "./openai-stream.js";
8
+ import { fromWireResponse, stopNotice, toWireItems, toWireTool, } from "./openai-wire.js";
9
+ const ID = "openai-codex";
10
+ const BASE = "https://chatgpt.com/backend-api/codex";
11
+ // Jecode's product version is unrelated to the Codex protocol gate. OpenAI's
12
+ // own catalogue updater uses this sentinel to request the complete current
13
+ // manifest; Jecode then keeps only entries explicitly visible in that manifest.
14
+ const CATALOG_COMPATIBILITY_VERSION = "99.99.99";
15
+ const SESSION_ID = randomUUID();
16
+ const MAX_CATALOG_ITEMS = 4_000;
17
+ const MAX_MODELS = 1_000;
18
+ const MAX_MODEL_CHARS = 256;
19
+ export const openaiCodex = {
20
+ id: ID,
21
+ defaultModel: "",
22
+ auth: { kind: "oauth", account: ID, label: "ChatGPT" },
23
+ blocked() {
24
+ return openAICodexAccount() === undefined ? "ChatGPT account is not connected" : undefined;
25
+ },
26
+ location: () => "cloud",
27
+ async models(signal, onStatus) {
28
+ return withAuthorization(async (authorization) => {
29
+ const body = await getJson(`${BASE}/models?client_version=${CATALOG_COMPATIBILITY_VERSION}`, headers(authorization, randomUUID()), signal, onStatus);
30
+ return modelIds(body);
31
+ }, signal, onStatus);
32
+ },
33
+ async send(req) {
34
+ return withAuthorization(async (authorization) => {
35
+ const events = await postSse(`${BASE}/responses`, {
36
+ ...headers(authorization, randomUUID()),
37
+ "openai-beta": "responses=experimental",
38
+ }, {
39
+ model: req.model,
40
+ store: false,
41
+ stream: true,
42
+ instructions: req.system,
43
+ input: req.messages.flatMap((message) => toWireItems(message, ID)),
44
+ tools: req.tools.map(toWireTool),
45
+ tool_choice: "auto",
46
+ parallel_tool_calls: true,
47
+ reasoning: { effort: req.effort, summary: "auto" },
48
+ text: { verbosity: "low" },
49
+ include: ["reasoning.encrypted_content"],
50
+ prompt_cache_key: SESSION_ID,
51
+ }, req.signal, req.onStatus);
52
+ const data = await assembleOpenAI(events, req.onStream);
53
+ const notice = stopNotice(data);
54
+ if (notice !== undefined)
55
+ req.onStream?.({ kind: "text", text: `\n${notice}` });
56
+ return fromWireResponse(data, ID);
57
+ }, req.signal, req.onStatus);
58
+ },
59
+ };
60
+ async function withAuthorization(operation, signal, onStatus) {
61
+ let authorization = await openAIAuthorization(undefined, signal, onStatus);
62
+ try {
63
+ return await operation(authorization);
64
+ }
65
+ catch (error) {
66
+ if (statusOf(error) !== 401)
67
+ throw error;
68
+ authorization = await openAIAuthorization(authorization.accessToken, signal, onStatus);
69
+ return operation(authorization);
70
+ }
71
+ }
72
+ function headers(authorization, requestId) {
73
+ const version = applicationVersion();
74
+ return {
75
+ authorization: `Bearer ${authorization.accessToken}`,
76
+ "chatgpt-account-id": authorization.accountId,
77
+ originator: "jecode",
78
+ "user-agent": `jecode/${version} (${process.platform}; ${process.arch})`,
79
+ "session-id": SESSION_ID,
80
+ "x-client-request-id": requestId,
81
+ };
82
+ }
83
+ function modelIds(value) {
84
+ const source = record(value) && Array.isArray(value["models"]) ? value["models"] : undefined;
85
+ if (source === undefined)
86
+ throw new Error("OpenAI Codex did not return a model list");
87
+ const seen = new Set();
88
+ return source
89
+ .slice(0, MAX_CATALOG_ITEMS)
90
+ .flatMap((entry) => {
91
+ if (!record(entry))
92
+ return [];
93
+ const id = entry["slug"];
94
+ if (typeof id !== "string" ||
95
+ id === "" ||
96
+ id.length > MAX_MODEL_CHARS ||
97
+ entry["visibility"] !== "list" ||
98
+ seen.has(id))
99
+ return [];
100
+ seen.add(id);
101
+ return [{ id, priority: typeof entry["priority"] === "number" ? entry["priority"] : 0 }];
102
+ })
103
+ .sort((left, right) => left.priority - right.priority)
104
+ .slice(0, MAX_MODELS)
105
+ .map((entry) => entry.id);
106
+ }
107
+ function statusOf(error) {
108
+ return typeof error === "object" && error !== null && "status" in error
109
+ ? error.status
110
+ : undefined;
111
+ }
112
+ function record(value) {
113
+ return typeof value === "object" && value !== null && !Array.isArray(value);
114
+ }
@@ -1,12 +1,11 @@
1
1
  // Reassembling an OpenAI Responses reply from its event stream.
2
2
  //
3
- // Unlike Anthropic, this stream ends with the whole finished response in
4
- // `response.completed`, so there is nothing to rebuild: the deltas drive the
5
- // display, and the final event is taken as authoritative. Items collected
6
- // along the way are only a fallback for a stream that ends without it.
3
+ // Unlike Anthropic, a standard Responses stream ends with the whole finished
4
+ // response in `response.completed`. The ChatGPT Codex backend can instead send
5
+ // an empty final `output` after complete `response.output_item.done` events, so
6
+ // those streamed items remain the fallback when the final envelope is empty.
7
7
  export async function assembleOpenAI(events, onStream) {
8
8
  const items = [];
9
- let completed;
10
9
  let refusal = false;
11
10
  for await (const raw of events) {
12
11
  const event = raw;
@@ -29,11 +28,10 @@ export async function assembleOpenAI(events, onStream) {
29
28
  if (event.item !== undefined)
30
29
  items.push(event.item);
31
30
  break;
31
+ case "response.done":
32
32
  case "response.completed":
33
33
  case "response.incomplete":
34
- if (event.response !== undefined)
35
- completed = event.response;
36
- break;
34
+ return reconcileOutput(event.response, items);
37
35
  case "response.failed": {
38
36
  const response = event.response;
39
37
  throw new Error(`openai stream error: ${response?.error?.message ?? "unspecified"}`);
@@ -44,5 +42,11 @@ export async function assembleOpenAI(events, onStream) {
44
42
  break;
45
43
  }
46
44
  }
47
- return completed ?? { output: items };
45
+ return { output: items };
46
+ }
47
+ function reconcileOutput(completed, items) {
48
+ if (completed === undefined)
49
+ return { output: items };
50
+ const finalCount = Array.isArray(completed.output) ? completed.output.length : 0;
51
+ return items.length > finalCount ? { ...completed, output: items } : completed;
48
52
  }
@@ -14,8 +14,8 @@ export function toWireTool(tool) {
14
14
  parameters: tool.input,
15
15
  };
16
16
  }
17
- export function toWireItems(message) {
18
- if (message.rawFrom === "openai" && Array.isArray(message.raw)) {
17
+ export function toWireItems(message, providerId = "openai") {
18
+ if (message.rawFrom === providerId && Array.isArray(message.raw)) {
19
19
  return message.raw;
20
20
  }
21
21
  const items = [];
@@ -50,7 +50,7 @@ export function stopNotice(data) {
50
50
  ? "[truncated: hit max_output_tokens — raise --max-tokens]"
51
51
  : `[incomplete: ${reason}]`;
52
52
  }
53
- export function fromWireResponse(data) {
53
+ export function fromWireResponse(data, providerId = "openai") {
54
54
  const raw = Array.isArray(data.output) ? data.output : [];
55
55
  const content = [];
56
56
  for (const entry of raw) {
@@ -81,7 +81,7 @@ export function fromWireResponse(data) {
81
81
  const notice = stopNotice(data);
82
82
  if (notice !== undefined)
83
83
  content.push({ kind: "text", text: notice });
84
- return { role: "assistant", content, raw, rawFrom: "openai", usage: normalizeUsage(data) };
84
+ return { role: "assistant", content, raw, rawFrom: providerId, usage: normalizeUsage(data) };
85
85
  }
86
86
  function normalizeUsage(data) {
87
87
  const usage = data.usage;
@@ -23,7 +23,7 @@ const NON_TEXT_MODE = /(?:^|[-_])(audio|realtime|transcribe|tts)(?:[-_]|$)/;
23
23
  export const openai = {
24
24
  id: "openai",
25
25
  defaultModel: "gpt-5",
26
- keyVar: KEY,
26
+ auth: { kind: "api-key", keyVar: KEY },
27
27
  blocked() {
28
28
  return apiKey() === undefined ? `${KEY} is not set` : undefined;
29
29
  },
@@ -41,7 +41,7 @@ export const openai = {
41
41
  const events = await postSse(ENDPOINT, headers(key), {
42
42
  model: req.model,
43
43
  instructions: req.system,
44
- input: req.messages.flatMap(toWireItems),
44
+ input: req.messages.flatMap((message) => toWireItems(message)),
45
45
  tools: req.tools.map(toWireTool),
46
46
  max_output_tokens: req.maxTokens,
47
47
  reasoning: { effort: normalizeEffort(req.effort), summary: "auto" },
@@ -76,6 +76,6 @@ function parseData(chunk) {
76
76
  return JSON.parse(data);
77
77
  }
78
78
  catch {
79
- return undefined;
79
+ throw new Error("SSE event contained invalid JSON");
80
80
  }
81
81
  }
@@ -25,7 +25,7 @@ export async function settingsCommand(session, host) {
25
25
  if (index === undefined)
26
26
  return;
27
27
  const action = items[index]?.action;
28
- if (action === undefined || action === "close")
28
+ if (action === undefined)
29
29
  return;
30
30
  selected = index;
31
31
  switch (action) {
@@ -75,14 +75,21 @@ function settingsItems(values) {
75
75
  }]),
76
76
  { action: "model", option: { label: "model", hint: values.model || "choose a model" } },
77
77
  { action: "effort", option: { label: "effort", hint: values.effort } },
78
- { action: "maxTokens", option: { label: "max output tokens", hint: String(values.maxTokens) } },
78
+ ...(values.maxTokens === undefined
79
+ ? []
80
+ : [{
81
+ action: "maxTokens",
82
+ option: { label: "max output tokens", hint: String(values.maxTokens) },
83
+ }]),
79
84
  { action: "maxSteps", option: { label: "max tool steps", hint: String(values.maxSteps) } },
80
85
  {
81
86
  action: "reducedMotion",
82
87
  option: { label: "reduced motion", hint: values.reducedMotion ? "on" : "off" },
83
88
  },
84
- { action: "credentials", option: { label: "credentials", hint: "manage API keys" } },
85
- { action: "close", option: { label: "close" } },
89
+ {
90
+ action: "credentials",
91
+ option: { label: "authentication", hint: "manage API keys and accounts" },
92
+ },
86
93
  ];
87
94
  }
88
95
  function settingsValues(session) {
@@ -91,7 +98,7 @@ function settingsValues(session) {
91
98
  model: session.model,
92
99
  ...(session.provider.id === "ollama" ? { ollamaConnection: ollamaConnectionHint() } : {}),
93
100
  effort: session.config.effort,
94
- maxTokens: session.config.maxTokens,
101
+ ...(session.provider.id === "openai-codex" ? {} : { maxTokens: session.config.maxTokens }),
95
102
  maxSteps: session.config.maxSteps,
96
103
  reducedMotion: session.config.reducedMotion,
97
104
  };
@@ -187,7 +194,7 @@ async function persist(host, patch) {
187
194
  return true;
188
195
  }
189
196
  catch (error) {
190
- host.emit({ kind: "notice", text: `could not save settings: ${error.message}`, tone: "error" });
197
+ host.emit({ kind: "notice", text: `could not save settings · ${error.message}`, tone: "error" });
191
198
  return false;
192
199
  }
193
200
  }
@@ -8,7 +8,7 @@ const MAX_OUTPUT_CHARS = 30_000;
8
8
  export const runCommand = {
9
9
  name: "run_command",
10
10
  description: "Run a shell command starting in the workspace root and return its combined stdout " +
11
- "and stderr. The shell is not a filesystem sandbox, so every call requires approval. Output is " +
11
+ "and stderr. The shell is not a filesystem sandbox, so calls ask for approval by default. Output is " +
12
12
  "truncated past 30000 characters.",
13
13
  dangerous: true,
14
14
  input: {
@@ -25,9 +25,6 @@ export function transcriptMarkdown(blocks) {
25
25
  case "notice":
26
26
  out.push(`> ${block.tone.toUpperCase()}: ${safeMultiline(block.text).replaceAll("\n", "\n> ")}`, "");
27
27
  break;
28
- case "list":
29
- out.push(...block.items.map((item) => safeInline(item.text)), "");
30
- break;
31
28
  }
32
29
  }
33
30
  while (out[out.length - 1] === "")
@@ -4,24 +4,33 @@ import { runTurn } from "../controller.js";
4
4
  import { updateSettings } from "../settings.js";
5
5
  import { saveTranscript } from "../transcript-export.js";
6
6
  import { recordUsage } from "../usage.js";
7
- import { answerAt, scopeFor } from "./approve.js";
7
+ import { answerAt } from "./approve.js";
8
+ import { cancel as cancelOpen } from "./overlay.js";
8
9
  import { controllerOptions, turnFailure } from "./session-view.js";
9
10
  import { transcribe } from "./turn.js";
10
11
  const WAITING = "Waiting";
11
12
  export function appWorkflows(options) {
12
- const { session, state, allowed, feedback } = options;
13
+ const { session, state, permissions, feedback } = options;
13
14
  async function command(text) {
14
15
  const activity = options.startActivity("command", `Running ${text.split(/\s+/)[0]}`);
15
16
  if (activity === undefined)
16
17
  return;
17
18
  try {
18
19
  const outcome = await handleCommand(text, session, {
19
- emit: options.commandOutput,
20
+ emit: options.commandNotice,
20
21
  signal: activity.control.signal,
22
+ showHelp: () => new Promise((resolve) => {
23
+ state.open = { help: true, settle: resolve };
24
+ options.render();
25
+ }),
21
26
  choose: (picker) => new Promise((resolve) => {
22
27
  state.open = { picker, settle: resolve };
23
28
  options.render();
24
29
  }),
30
+ dismiss: () => {
31
+ state.open = state.open === undefined ? undefined : cancelOpen(state.open);
32
+ options.render();
33
+ },
25
34
  type: (field) => new Promise((resolve) => {
26
35
  state.open = { field, settle: resolve };
27
36
  options.render();
@@ -33,19 +42,13 @@ export function appWorkflows(options) {
33
42
  reset: () => {
34
43
  state.blocks.splice(0);
35
44
  state.past.length = 0;
36
- allowed.clear();
45
+ permissions.reset();
37
46
  state.scroll = 0;
38
47
  state.follow = true;
39
48
  state.unseen = 0;
40
49
  state.lastMaxScroll = 0;
41
50
  },
42
- permissions: () => [...allowed].map(([key, label]) => ({ key, label })),
43
- revokePermission: (key) => {
44
- if (key === undefined)
45
- allowed.clear();
46
- else
47
- allowed.delete(key);
48
- },
51
+ permissions,
49
52
  exportTranscript: () => saveTranscript(options.transcriptRoot, state.blocks),
50
53
  saveSettings: async (patch) => {
51
54
  await updateSettings(patch);
@@ -76,11 +79,8 @@ export function appWorkflows(options) {
76
79
  emit: options.emit,
77
80
  render: options.render,
78
81
  palette: session.palette,
79
- approved: (call) => session.config.autoApprove || allowed.has(scopeFor(call).key),
80
- remember: (call) => {
81
- const scope = scopeFor(call);
82
- allowed.set(scope.key, scope.summary);
83
- },
82
+ approved: (call) => permissions.approved(call),
83
+ remember: (call) => permissions.remember(call),
84
84
  ask: (prompt, settle) => {
85
85
  state.open = { picker: prompt, settle: (index) => settle(answerAt(index)) };
86
86
  options.render();
@@ -92,7 +92,7 @@ export function appWorkflows(options) {
92
92
  });
93
93
  let finishReason;
94
94
  try {
95
- await runTurn(session.history, controllerOptions(session), events, activity.control.signal);
95
+ await runTurn(session.history, controllerOptions(session, permissions.availableTools()), events, activity.control.signal);
96
96
  }
97
97
  catch (error) {
98
98
  const interrupted = activity.control.signal.aborted;
package/dist/tui/app.js CHANGED
@@ -18,6 +18,7 @@ import { transcriptRenderer } from "./transcript-view.js";
18
18
  import { appState } from "./app-state.js";
19
19
  import { appInput } from "./app-input.js";
20
20
  import { appWorkflows } from "./app-workflows.js";
21
+ import { sessionPermissions } from "../permissions.js";
21
22
  const FRAME_MS = 16;
22
23
  const SPIN_MS = 80;
23
24
  /** How long a lone escape waits to prove it is not the start of a sequence. */
@@ -29,9 +30,7 @@ export async function runApp(session, transcriptRoot, environment = {}) {
29
30
  const transcript = transcriptRenderer();
30
31
  const workspace = await workspaceLabel(session.config.root);
31
32
  const state = appState();
32
- // Tools the user said "always" to. It lives for the window and dies with it:
33
- // a permission granted once, in one conversation, is not a setting.
34
- const allowed = new Map();
33
+ const permissions = sessionPermissions(session.tools, session.config.autoApprove);
35
34
  let closed;
36
35
  let frameTimer;
37
36
  let spinTimer;
@@ -100,12 +99,8 @@ export async function runApp(session, transcriptRoot, environment = {}) {
100
99
  else
101
100
  state.unseen++;
102
101
  };
103
- const commandOutput = (block) => {
104
- const next = commandFeedback(block);
105
- if (next === undefined)
106
- emit(block);
107
- else
108
- feedback.show(next);
102
+ const commandNotice = (notice) => {
103
+ feedback.show(commandFeedback(notice));
109
104
  };
110
105
  const scrollBy = (amount) => {
111
106
  state.scroll = Math.max(0, state.scroll + amount);
@@ -177,10 +172,10 @@ export async function runApp(session, transcriptRoot, environment = {}) {
177
172
  session,
178
173
  transcriptRoot,
179
174
  state,
180
- allowed,
175
+ permissions,
181
176
  feedback,
182
177
  emit,
183
- commandOutput,
178
+ commandNotice,
184
179
  render,
185
180
  refreshSettings: () => {
186
181
  terminal.setReducedMotion(session.config.reducedMotion);