@giovannijecha/jecode 0.1.9 → 0.2.1

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 (42) hide show
  1. package/README.md +36 -18
  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 +1 -1
  6. package/dist/controller.js +46 -11
  7. package/dist/credential-commands.js +32 -4
  8. package/dist/credential-safety.js +10 -1
  9. package/dist/effort.js +25 -0
  10. package/dist/external-browser.js +53 -0
  11. package/dist/oauth-http.js +114 -0
  12. package/dist/openai-account-command.js +124 -0
  13. package/dist/openai-account.js +94 -0
  14. package/dist/openai-oauth-callback.js +186 -0
  15. package/dist/openai-oauth-tokens.js +65 -0
  16. package/dist/openai-oauth.js +203 -0
  17. package/dist/provider-commands.js +79 -38
  18. package/dist/provider-label.js +10 -0
  19. package/dist/providers/anthropic-stream.js +8 -2
  20. package/dist/providers/anthropic.js +22 -6
  21. package/dist/providers/index.js +2 -1
  22. package/dist/providers/ollama-stream.js +3 -0
  23. package/dist/providers/ollama.js +4 -1
  24. package/dist/providers/openai-codex.js +157 -0
  25. package/dist/providers/openai-stream.js +13 -9
  26. package/dist/providers/openai-wire.js +4 -9
  27. package/dist/providers/openai.js +36 -15
  28. package/dist/providers/sse.js +1 -1
  29. package/dist/settings-command.js +64 -9
  30. package/dist/settings.js +2 -1
  31. package/dist/tools/args.js +4 -3
  32. package/dist/tools/fs.js +8 -3
  33. package/dist/tools/index.js +2 -2
  34. package/dist/tools/shell.js +2 -0
  35. package/dist/tui/app-workflows.js +5 -0
  36. package/dist/tui/components/messages.js +26 -5
  37. package/dist/tui/feedback.js +10 -6
  38. package/dist/tui/session-view.js +10 -4
  39. package/dist/tui/turn.js +4 -1
  40. package/dist/version.js +14 -0
  41. package/docs/assets/brand/jeco-256.png +0 -0
  42. package/package.json +5 -1
@@ -0,0 +1,157 @@
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 { EFFORTS, isEffort, requireSupportedEffort } from "../effort.js";
7
+ import { getJson, postSse } from "./http.js";
8
+ import { assembleOpenAI } from "./openai-stream.js";
9
+ import { fromWireResponse, stopNotice, toWireItems, toWireTool, } from "./openai-wire.js";
10
+ const ID = "openai-codex";
11
+ const BASE = "https://chatgpt.com/backend-api/codex";
12
+ // Jecode's product version is unrelated to the Codex protocol gate. OpenAI's
13
+ // own catalogue updater uses this sentinel to request the complete current
14
+ // manifest; Jecode then keeps only entries explicitly visible in that manifest.
15
+ const CATALOG_COMPATIBILITY_VERSION = "99.99.99";
16
+ const SESSION_ID = randomUUID();
17
+ const MAX_CATALOG_ITEMS = 4_000;
18
+ const MAX_MODELS = 1_000;
19
+ const MAX_MODEL_CHARS = 256;
20
+ const XHIGH_EFFORTS = ["low", "medium", "high", "xhigh"];
21
+ let effortByModel = new Map();
22
+ export const openaiCodex = {
23
+ id: ID,
24
+ defaultModel: "",
25
+ auth: { kind: "oauth", account: ID, label: "ChatGPT" },
26
+ blocked() {
27
+ return openAICodexAccount() === undefined ? "ChatGPT account is not connected" : undefined;
28
+ },
29
+ location: () => "cloud",
30
+ async models(signal, onStatus) {
31
+ const catalog = await loadCatalog(signal, onStatus);
32
+ effortByModel = catalog.efforts;
33
+ return catalog.ids;
34
+ },
35
+ async efforts(model, signal, onStatus) {
36
+ const cached = effortByModel.get(model);
37
+ if (cached !== undefined)
38
+ return cached;
39
+ const catalog = await loadCatalog(signal, onStatus);
40
+ effortByModel = catalog.efforts;
41
+ return effortByModel.get(model) ?? fallbackEfforts(model);
42
+ },
43
+ async send(req) {
44
+ const efforts = effortByModel.get(req.model) ?? fallbackEfforts(req.model);
45
+ const effort = requireSupportedEffort(req.model, req.effort, efforts);
46
+ return withAuthorization(async (authorization) => {
47
+ const events = await postSse(`${BASE}/responses`, {
48
+ ...headers(authorization, randomUUID()),
49
+ "openai-beta": "responses=experimental",
50
+ }, {
51
+ model: req.model,
52
+ store: false,
53
+ stream: true,
54
+ instructions: req.system,
55
+ input: req.messages.flatMap((message) => toWireItems(message, ID)),
56
+ tools: req.tools.map(toWireTool),
57
+ tool_choice: "auto",
58
+ parallel_tool_calls: true,
59
+ reasoning: { effort, summary: "auto" },
60
+ text: { verbosity: "low" },
61
+ include: ["reasoning.encrypted_content"],
62
+ prompt_cache_key: SESSION_ID,
63
+ }, req.signal, req.onStatus);
64
+ const data = await assembleOpenAI(events, req.onStream);
65
+ const notice = stopNotice(data);
66
+ if (notice !== undefined)
67
+ req.onStream?.({ kind: "text", text: `\n${notice}` });
68
+ return fromWireResponse(data, ID);
69
+ }, req.signal, req.onStatus);
70
+ },
71
+ };
72
+ async function loadCatalog(signal, onStatus) {
73
+ return withAuthorization(async (authorization) => {
74
+ const body = await getJson(`${BASE}/models?client_version=${CATALOG_COMPATIBILITY_VERSION}`, headers(authorization, randomUUID()), signal, onStatus);
75
+ return modelCatalog(body);
76
+ }, signal, onStatus);
77
+ }
78
+ async function withAuthorization(operation, signal, onStatus) {
79
+ let authorization = await openAIAuthorization(undefined, signal, onStatus);
80
+ try {
81
+ return await operation(authorization);
82
+ }
83
+ catch (error) {
84
+ if (statusOf(error) !== 401)
85
+ throw error;
86
+ authorization = await openAIAuthorization(authorization.accessToken, signal, onStatus);
87
+ return operation(authorization);
88
+ }
89
+ }
90
+ function headers(authorization, requestId) {
91
+ const version = applicationVersion();
92
+ return {
93
+ authorization: `Bearer ${authorization.accessToken}`,
94
+ "chatgpt-account-id": authorization.accountId,
95
+ originator: "jecode",
96
+ "user-agent": `jecode/${version} (${process.platform}; ${process.arch})`,
97
+ "session-id": SESSION_ID,
98
+ "x-client-request-id": requestId,
99
+ };
100
+ }
101
+ function modelCatalog(value) {
102
+ const source = record(value) && Array.isArray(value["models"]) ? value["models"] : undefined;
103
+ if (source === undefined)
104
+ throw new Error("OpenAI Codex did not return a model list");
105
+ const seen = new Set();
106
+ const models = source
107
+ .slice(0, MAX_CATALOG_ITEMS)
108
+ .flatMap((entry) => {
109
+ if (!record(entry))
110
+ return [];
111
+ const id = entry["slug"];
112
+ if (typeof id !== "string" ||
113
+ id === "" ||
114
+ id.length > MAX_MODEL_CHARS ||
115
+ entry["visibility"] !== "list" ||
116
+ seen.has(id))
117
+ return [];
118
+ seen.add(id);
119
+ return [{
120
+ id,
121
+ priority: typeof entry["priority"] === "number" ? entry["priority"] : 0,
122
+ efforts: reasoningLevels(entry, id),
123
+ }];
124
+ })
125
+ .sort((left, right) => left.priority - right.priority)
126
+ .slice(0, MAX_MODELS);
127
+ return {
128
+ ids: models.map((entry) => entry.id),
129
+ efforts: new Map(models.map((entry) => [entry.id, entry.efforts])),
130
+ };
131
+ }
132
+ function reasoningLevels(entry, model) {
133
+ const source = entry["supported_reasoning_levels"];
134
+ if (!Array.isArray(source))
135
+ return fallbackEfforts(model);
136
+ const seen = new Set();
137
+ const efforts = source.flatMap((level) => {
138
+ if (!record(level) || !isEffort(level["effort"]) || seen.has(level["effort"]))
139
+ return [];
140
+ seen.add(level["effort"]);
141
+ return [level["effort"]];
142
+ });
143
+ return efforts.length === 0 ? fallbackEfforts(model) : efforts;
144
+ }
145
+ function fallbackEfforts(model) {
146
+ if (/^gpt-5\.6-(?:sol|terra|luna)(?:-|$)/.test(model))
147
+ return EFFORTS;
148
+ return XHIGH_EFFORTS;
149
+ }
150
+ function statusOf(error) {
151
+ return typeof error === "object" && error !== null && "status" in error
152
+ ? error.status
153
+ : undefined;
154
+ }
155
+ function record(value) {
156
+ return typeof value === "object" && value !== null && !Array.isArray(value);
157
+ }
@@ -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
+ throw new Error("openai stream ended before a terminal response event");
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
  }
@@ -1,11 +1,6 @@
1
1
  // Translation between the normalized vocabulary and the OpenAI Responses wire
2
2
  // shape: a flat `input` list where tool calls and their outputs are top-level
3
3
  // items keyed by `call_id`, rather than blocks nested inside a message.
4
- // The Responses API has no `xhigh`; collapse it onto the nearest level rather
5
- // than passing through a value it will reject.
6
- export function normalizeEffort(effort) {
7
- return effort === "xhigh" || effort === "max" ? "high" : effort;
8
- }
9
4
  export function toWireTool(tool) {
10
5
  return {
11
6
  type: "function",
@@ -14,8 +9,8 @@ export function toWireTool(tool) {
14
9
  parameters: tool.input,
15
10
  };
16
11
  }
17
- export function toWireItems(message) {
18
- if (message.rawFrom === "openai" && Array.isArray(message.raw)) {
12
+ export function toWireItems(message, providerId = "openai") {
13
+ if (message.rawFrom === providerId && Array.isArray(message.raw)) {
19
14
  return message.raw;
20
15
  }
21
16
  const items = [];
@@ -50,7 +45,7 @@ export function stopNotice(data) {
50
45
  ? "[truncated: hit max_output_tokens — raise --max-tokens]"
51
46
  : `[incomplete: ${reason}]`;
52
47
  }
53
- export function fromWireResponse(data) {
48
+ export function fromWireResponse(data, providerId = "openai") {
54
49
  const raw = Array.isArray(data.output) ? data.output : [];
55
50
  const content = [];
56
51
  for (const entry of raw) {
@@ -81,7 +76,7 @@ export function fromWireResponse(data) {
81
76
  const notice = stopNotice(data);
82
77
  if (notice !== undefined)
83
78
  content.push({ kind: "text", text: notice });
84
- return { role: "assistant", content, raw, rawFrom: "openai", usage: normalizeUsage(data) };
79
+ return { role: "assistant", content, raw, rawFrom: providerId, usage: normalizeUsage(data) };
85
80
  }
86
81
  function normalizeUsage(data) {
87
82
  const usage = data.usage;
@@ -5,25 +5,42 @@
5
5
  import { postSse } from "./http.js";
6
6
  import { listModels } from "./catalog.js";
7
7
  import { keyFor } from "../credentials.js";
8
+ import { EFFORTS, requireSupportedEffort } from "../effort.js";
8
9
  import { assembleOpenAI } from "./openai-stream.js";
9
- import { fromWireResponse, normalizeEffort, stopNotice, toWireItems, toWireTool, } from "./openai-wire.js";
10
+ import { fromWireResponse, stopNotice, toWireItems, toWireTool, } from "./openai-wire.js";
10
11
  const ENDPOINT = "https://api.openai.com/v1/responses";
11
12
  const MODELS = "https://api.openai.com/v1/models";
12
13
  const KEY = "OPENAI_API_KEY";
13
- /**
14
- * What this account can reach that is not a chat model.
15
- *
16
- * The list is an exclusion rather than an allow-list on purpose: an unknown
17
- * `gpt-`something is far more likely to be a model worth offering than one
18
- * worth hiding, and an allow-list would quietly bury every family shipped
19
- * after this line was written.
20
- */
21
- const NOT_CHAT = /^(text-|tts-|whisper|dall-e|sora|gpt-image|omni-moderation|davinci|babbage)/;
22
- const NON_TEXT_MODE = /(?:^|[-_])(audio|realtime|transcribe|tts)(?:[-_]|$)/;
14
+ const RESPONSES_REASONING_MODEL = /^(?:gpt-5(?:[.-]|$)|o(?:1|3|4)(?:[.-]|$)|codex-mini(?:[.-]|$))/;
15
+ // Jecode's transport always streams and always declares local tools. Hide
16
+ // catalog entries that cannot satisfy either half of that contract.
17
+ const INCOMPATIBLE_MODEL = /^(?:gpt-5(?:\.[1-3])?-chat-latest|gpt-5\.5-pro|o1-mini|o(?:1|3)-pro|o3-deep-research|o4-mini-deep-research)(?:-|$)/;
18
+ const STANDARD_EFFORTS = ["low", "medium", "high"];
19
+ const XHIGH_EFFORTS = ["low", "medium", "high", "xhigh"];
20
+ const PRO_EFFORTS = ["medium", "high", "xhigh"];
21
+ const HIGH_ONLY_EFFORT = ["high"];
22
+ export function supportsOpenAIModel(model) {
23
+ return RESPONSES_REASONING_MODEL.test(model) && !INCOMPATIBLE_MODEL.test(model);
24
+ }
25
+ export function openAIEfforts(model) {
26
+ if (!supportsOpenAIModel(model))
27
+ return [];
28
+ if (/^gpt-5-pro(?:-|$)/.test(model))
29
+ return HIGH_ONLY_EFFORT;
30
+ if (/^gpt-5\.[2-5]-pro(?:-|$)/.test(model))
31
+ return PRO_EFFORTS;
32
+ if (/^gpt-5\.6(?:[.-]|$)/.test(model))
33
+ return EFFORTS;
34
+ if (/^gpt-5\.[2-5](?:[.-]|$)/.test(model))
35
+ return XHIGH_EFFORTS;
36
+ if (/^(?:o(?:1|3|4)|codex-mini)(?:[.-]|$)/.test(model))
37
+ return STANDARD_EFFORTS;
38
+ return STANDARD_EFFORTS;
39
+ }
23
40
  export const openai = {
24
41
  id: "openai",
25
42
  defaultModel: "gpt-5",
26
- keyVar: KEY,
43
+ auth: { kind: "api-key", keyVar: KEY },
27
44
  blocked() {
28
45
  return apiKey() === undefined ? `${KEY} is not set` : undefined;
29
46
  },
@@ -32,19 +49,23 @@ export const openai = {
32
49
  async models(signal, onStatus) {
33
50
  const ids = await listModels(MODELS, headers(requireKey()), signal, onStatus);
34
51
  return ids
35
- .filter((id) => !NOT_CHAT.test(id) && !NON_TEXT_MODE.test(id))
52
+ .filter(supportsOpenAIModel)
36
53
  .sort((a, b) => b.localeCompare(a));
37
54
  },
55
+ async efforts(model) {
56
+ return openAIEfforts(model);
57
+ },
38
58
  location: () => "cloud",
39
59
  async send(req) {
40
60
  const key = requireKey();
61
+ const effort = requireSupportedEffort(req.model, req.effort, openAIEfforts(req.model));
41
62
  const events = await postSse(ENDPOINT, headers(key), {
42
63
  model: req.model,
43
64
  instructions: req.system,
44
- input: req.messages.flatMap(toWireItems),
65
+ input: req.messages.flatMap((message) => toWireItems(message)),
45
66
  tools: req.tools.map(toWireTool),
46
67
  max_output_tokens: req.maxTokens,
47
- reasoning: { effort: normalizeEffort(req.effort), summary: "auto" },
68
+ reasoning: { effort, summary: "auto" },
48
69
  store: false,
49
70
  include: ["reasoning.encrypted_content"],
50
71
  stream: true,
@@ -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
  }
@@ -3,6 +3,7 @@
3
3
  import { modelsCommand, providersCommand } from "./provider-commands.js";
4
4
  import { credentialsCommand } from "./credential-commands.js";
5
5
  import { EFFORTS, readSettings, settingsLabel, updateSettings } from "./settings.js";
6
+ import { providerFailure } from "./provider-errors.js";
6
7
  import { ollamaConnectionHint, ollamaConnectionSetting, } from "./ollama-settings-command.js";
7
8
  import { of } from "./tui/editor.js";
8
9
  import { heading } from "./tui/picker.js";
@@ -75,13 +76,21 @@ function settingsItems(values) {
75
76
  }]),
76
77
  { action: "model", option: { label: "model", hint: values.model || "choose a model" } },
77
78
  { action: "effort", option: { label: "effort", hint: values.effort } },
78
- { action: "maxTokens", option: { label: "max output tokens", hint: String(values.maxTokens) } },
79
+ ...(values.maxTokens === undefined
80
+ ? []
81
+ : [{
82
+ action: "maxTokens",
83
+ option: { label: "max output tokens", hint: String(values.maxTokens) },
84
+ }]),
79
85
  { action: "maxSteps", option: { label: "max tool steps", hint: String(values.maxSteps) } },
80
86
  {
81
87
  action: "reducedMotion",
82
88
  option: { label: "reduced motion", hint: values.reducedMotion ? "on" : "off" },
83
89
  },
84
- { action: "credentials", option: { label: "credentials", hint: "manage API keys" } },
90
+ {
91
+ action: "credentials",
92
+ option: { label: "authentication", hint: "manage API keys and accounts" },
93
+ },
85
94
  ];
86
95
  }
87
96
  function settingsValues(session) {
@@ -90,7 +99,7 @@ function settingsValues(session) {
90
99
  model: session.model,
91
100
  ...(session.provider.id === "ollama" ? { ollamaConnection: ollamaConnectionHint() } : {}),
92
101
  effort: session.config.effort,
93
- maxTokens: session.config.maxTokens,
102
+ ...(session.provider.id === "openai-codex" ? {} : { maxTokens: session.config.maxTokens }),
94
103
  maxSteps: session.config.maxSteps,
95
104
  reducedMotion: session.config.reducedMotion,
96
105
  };
@@ -101,6 +110,7 @@ async function providerSetting(session, host) {
101
110
  model: session.model,
102
111
  providerId: session.config.providerId,
103
112
  configModel: session.config.model,
113
+ effort: session.config.effort,
104
114
  };
105
115
  if (!(await providersCommand(session, host, { announce: false, save: false })))
106
116
  return;
@@ -108,40 +118,85 @@ async function providerSetting(session, host) {
108
118
  const models = { ...current.models };
109
119
  if (session.model !== "")
110
120
  models[session.provider.id] = session.model;
111
- if (await persist(host, { provider: session.provider.id, models }))
121
+ const patch = {
122
+ provider: session.provider.id,
123
+ models,
124
+ ...(session.config.effort === before.effort ? {} : { effort: session.config.effort }),
125
+ };
126
+ if (await persist(host, patch))
112
127
  return;
113
128
  session.provider = before.provider;
114
129
  session.model = before.model;
115
130
  session.config.providerId = before.providerId;
116
131
  session.config.model = before.configModel;
132
+ session.config.effort = before.effort;
117
133
  }
118
134
  async function modelSetting(session, host) {
119
- const before = { model: session.model, configModel: session.config.model };
135
+ const before = {
136
+ model: session.model,
137
+ configModel: session.config.model,
138
+ effort: session.config.effort,
139
+ };
120
140
  if (!(await modelsCommand(session, host, { announce: false, save: false })))
121
141
  return;
122
142
  const current = readSettings();
123
143
  const models = { ...current.models, [session.provider.id]: session.model };
124
- if (await persist(host, { models }))
144
+ const patch = {
145
+ models,
146
+ ...(session.config.effort === before.effort ? {} : { effort: session.config.effort }),
147
+ };
148
+ if (await persist(host, patch))
125
149
  return;
126
150
  session.model = before.model;
127
151
  session.config.model = before.configModel;
152
+ session.config.effort = before.effort;
128
153
  }
129
154
  async function effortSetting(session, host) {
130
155
  const choose = chooser(host);
131
156
  if (choose === undefined)
132
157
  return;
158
+ const efforts = await availableEfforts(session, host);
159
+ if (efforts === undefined)
160
+ return;
161
+ if (efforts.length === 0) {
162
+ host.emit({
163
+ kind: "notice",
164
+ text: `${session.model || session.provider.id} controls its own reasoning depth`,
165
+ tone: "info",
166
+ });
167
+ return;
168
+ }
133
169
  const current = session.config.effort;
134
170
  const index = await choose({
135
171
  title: heading("effort", "saved default", session.palette),
136
- options: EFFORTS.map((value) => ({ label: value })),
137
- index: Math.max(0, EFFORTS.findIndex((value) => value === current)),
172
+ options: efforts.map((value) => ({ label: value })),
173
+ index: Math.max(0, efforts.findIndex((value) => value === current)),
138
174
  });
139
- const value = index === undefined ? undefined : EFFORTS[index];
175
+ const value = index === undefined ? undefined : efforts[index];
140
176
  if (value === undefined || !(await persist(host, { effort: value })))
141
177
  return;
142
178
  session.config.effort = value;
143
179
  return value;
144
180
  }
181
+ async function availableEfforts(session, host) {
182
+ if (session.provider.efforts === undefined)
183
+ return EFFORTS;
184
+ host.status?.(`Asking ${session.provider.id}`);
185
+ try {
186
+ return await session.provider.efforts(session.model, host.signal, (status) => host.status?.(status));
187
+ }
188
+ catch (error) {
189
+ host.emit({
190
+ kind: "notice",
191
+ text: providerFailure(session.provider, error, true),
192
+ tone: "error",
193
+ });
194
+ return undefined;
195
+ }
196
+ finally {
197
+ host.status?.(undefined);
198
+ }
199
+ }
145
200
  async function motionSetting(session, host) {
146
201
  const choose = chooser(host);
147
202
  if (choose === undefined)
package/dist/settings.js CHANGED
@@ -3,10 +3,11 @@ import { readFileSync } from "node:fs";
3
3
  import { chmod, mkdir } from "node:fs/promises";
4
4
  import * as path from "node:path";
5
5
  import { atomicWrite } from "./atomic.js";
6
+ import { EFFORTS } from "./effort.js";
6
7
  import { providerNames } from "./providers/index.js";
7
8
  import { parseOllamaEndpoint } from "./providers/ollama-endpoint.js";
8
9
  import { userDataLabel, userDataPath } from "./user-data.js";
9
- export const EFFORTS = ["low", "medium", "high", "xhigh", "max"];
10
+ export { EFFORTS } from "./effort.js";
10
11
  let saved;
11
12
  export function readSettings() {
12
13
  if (saved === undefined)
@@ -3,10 +3,11 @@
3
3
  //
4
4
  // Every throw here becomes an is_error tool result the model can read and
5
5
  // correct on the next step, so the messages are written for that reader.
6
- export function requireString(args, name) {
6
+ export function requireString(args, name, allowEmpty = false) {
7
7
  const value = args[name];
8
- if (typeof value !== "string" || value === "") {
9
- throw new Error(`"${name}" is required and must be a non-empty string`);
8
+ if (typeof value !== "string" || (!allowEmpty && value === "")) {
9
+ const kind = allowEmpty ? "a string" : "a non-empty string";
10
+ throw new Error(`"${name}" is required and must be ${kind}`);
10
11
  }
11
12
  return value;
12
13
  }
package/dist/tools/fs.js CHANGED
@@ -101,7 +101,7 @@ export const writeFile = {
101
101
  async preview(args, ctx) {
102
102
  const root = await resolveExistingInRoot(ctx.root, ".");
103
103
  const target = await resolveDirectWritableInRoot(root, requireString(args, "path"));
104
- const content = requireString(args, "content");
104
+ const content = requireString(args, "content", true);
105
105
  assertEditableText(content);
106
106
  // A write against a file that is already there is a replacement, and the
107
107
  // user is owed the difference rather than a wall of green.
@@ -110,7 +110,7 @@ export const writeFile = {
110
110
  async run(args, ctx) {
111
111
  const root = await resolveExistingInRoot(ctx.root, ".");
112
112
  const target = await resolveDirectWritableInRoot(root, requireString(args, "path"));
113
- const content = requireString(args, "content");
113
+ const content = requireString(args, "content", true);
114
114
  assertEditableText(content);
115
115
  await fs.mkdir(path.dirname(target), { recursive: true });
116
116
  const validate = () => assertDirectWritableInRoot(root, target);
@@ -248,7 +248,10 @@ function applied(before, args) {
248
248
  }
249
249
  const made = replaceAll ? occurrences : 1;
250
250
  assertReplacementFits(before, oldText, newText, made);
251
- const after = replaceAll ? before.replaceAll(oldText, newText) : before.replace(oldText, newText);
251
+ const replacement = () => newText;
252
+ const after = replaceAll
253
+ ? before.replaceAll(oldText, replacement)
254
+ : before.replace(oldText, replacement);
252
255
  assertEditableText(after, "edited content");
253
256
  return { after, made };
254
257
  }
@@ -270,6 +273,8 @@ async function unchangedSinceApproval(target, approved) {
270
273
  }
271
274
  }
272
275
  function count(text, noun) {
276
+ if (text === "")
277
+ return "empty";
273
278
  return plural(text.split("\n").length, noun, `${noun}s`);
274
279
  }
275
280
  function plural(n, one, many) {
@@ -20,8 +20,8 @@ export function toolSpecs(tools) {
20
20
  // and gets another turn to fix its call. Only an aborted turn propagates.
21
21
  export async function runTool(tool, call, ctx) {
22
22
  try {
23
- const { output, summary } = await tool.run(call.input, ctx);
24
- return { result: { kind: "tool_result", id: call.id, output, isError: false }, summary };
23
+ const { output, summary, isError = false } = await tool.run(call.input, ctx);
24
+ return { result: { kind: "tool_result", id: call.id, output, isError }, summary };
25
25
  }
26
26
  catch (error) {
27
27
  if (ctx.signal?.aborted === true)
@@ -32,6 +32,7 @@ export const runCommand = {
32
32
  return {
33
33
  output: output === "" ? `[${summary}]` : `${output}\n[${summary}]`,
34
34
  summary,
35
+ isError: result.timedOut || result.code !== 0,
35
36
  };
36
37
  },
37
38
  };
@@ -45,6 +46,7 @@ function execute(command, cwd, timeoutMs, signal, onOutput) {
45
46
  shell: true,
46
47
  windowsHide: true,
47
48
  detached: process.platform !== "win32",
49
+ stdio: ["ignore", "pipe", "pipe"],
48
50
  });
49
51
  const output = capture(onOutput);
50
52
  let timedOut = false;
@@ -5,6 +5,7 @@ import { updateSettings } from "../settings.js";
5
5
  import { saveTranscript } from "../transcript-export.js";
6
6
  import { recordUsage } from "../usage.js";
7
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";
@@ -26,6 +27,10 @@ export function appWorkflows(options) {
26
27
  state.open = { picker, settle: resolve };
27
28
  options.render();
28
29
  }),
30
+ dismiss: () => {
31
+ state.open = state.open === undefined ? undefined : cancelOpen(state.open);
32
+ options.render();
33
+ },
29
34
  type: (field) => new Promise((resolve) => {
30
35
  state.open = { field, settle: resolve };
31
36
  options.render();
@@ -2,6 +2,8 @@ import { blank, row } from "../../ui/render.js";
2
2
  import { markdown } from "../../ui/markdown.js";
3
3
  const PAD = 1;
4
4
  export const REASONING_PREVIEW_ROWS = 3;
5
+ const MIN_REASONING_PREVIEW_CHARS = 4_096;
6
+ const REASONING_PREVIEW_OVERSCAN = 12;
5
7
  export function renderUser(block, width, pal) {
6
8
  const inner = Math.max(8, width - PAD * 2);
7
9
  const content = markdown(block.text, inner, pal, inner);
@@ -21,14 +23,21 @@ export function renderAnswer(block, width, pal) {
21
23
  }
22
24
  export function renderReasoning(block, width, pal) {
23
25
  const inner = Math.max(8, width - PAD * 2);
24
- const content = markdown(block.text, inner, pal, inner);
25
- const expanded = block.expanded === true;
26
+ // Expanding a live stream is deferred until it is sealed. Re-parsing an
27
+ // ever-growing full thought on every token makes the whole TUI stall.
28
+ const expanded = block.expanded === true && block.live !== true;
29
+ const source = !expanded
30
+ ? reasoningPreviewSource(block.text, inner)
31
+ : { text: block.text, truncated: false };
32
+ const content = markdown(source.text, inner, pal, inner);
26
33
  const visible = expanded ? content : content.slice(-REASONING_PREVIEW_ROWS);
27
34
  const action = expanded
28
35
  ? "ctrl+o compact"
29
- : content.length > REASONING_PREVIEW_ROWS
30
- ? "ctrl+o full"
31
- : undefined;
36
+ : block.live === true && block.expanded === true
37
+ ? "full when done"
38
+ : source.truncated || content.length > REASONING_PREVIEW_ROWS
39
+ ? "ctrl+o full"
40
+ : undefined;
32
41
  return [
33
42
  "",
34
43
  row(width, [
@@ -41,3 +50,15 @@ export function renderReasoning(block, width, pal) {
41
50
  ...visible.map((line) => row(width, line.segs.map((seg) => ({ ...seg, fg: pal.ink.muted, italic: true })), [], undefined, PAD)),
42
51
  ];
43
52
  }
53
+ export function reasoningPreviewSource(text, width) {
54
+ const limit = Math.max(MIN_REASONING_PREVIEW_CHARS, width * REASONING_PREVIEW_ROWS * REASONING_PREVIEW_OVERSCAN);
55
+ if (text.length <= limit)
56
+ return { text, truncated: false };
57
+ // A compact view only needs its visible tail. The complete text remains on
58
+ // the block for expansion after the reasoning stream is sealed.
59
+ let start = text.length - limit;
60
+ const code = text.charCodeAt(start);
61
+ if (code >= 0xdc00 && code <= 0xdfff)
62
+ start--;
63
+ return { text: text.slice(start), truncated: true };
64
+ }