@giovannijecha/jecode 0.2.0 → 0.2.2

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.
@@ -5,21 +5,38 @@
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",
@@ -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
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,
@@ -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";
@@ -109,6 +110,7 @@ async function providerSetting(session, host) {
109
110
  model: session.model,
110
111
  providerId: session.config.providerId,
111
112
  configModel: session.config.model,
113
+ effort: session.config.effort,
112
114
  };
113
115
  if (!(await providersCommand(session, host, { announce: false, save: false })))
114
116
  return;
@@ -116,40 +118,85 @@ async function providerSetting(session, host) {
116
118
  const models = { ...current.models };
117
119
  if (session.model !== "")
118
120
  models[session.provider.id] = session.model;
119
- 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))
120
127
  return;
121
128
  session.provider = before.provider;
122
129
  session.model = before.model;
123
130
  session.config.providerId = before.providerId;
124
131
  session.config.model = before.configModel;
132
+ session.config.effort = before.effort;
125
133
  }
126
134
  async function modelSetting(session, host) {
127
- 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
+ };
128
140
  if (!(await modelsCommand(session, host, { announce: false, save: false })))
129
141
  return;
130
142
  const current = readSettings();
131
143
  const models = { ...current.models, [session.provider.id]: session.model };
132
- 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))
133
149
  return;
134
150
  session.model = before.model;
135
151
  session.config.model = before.configModel;
152
+ session.config.effort = before.effort;
136
153
  }
137
154
  async function effortSetting(session, host) {
138
155
  const choose = chooser(host);
139
156
  if (choose === undefined)
140
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
+ }
141
169
  const current = session.config.effort;
142
170
  const index = await choose({
143
171
  title: heading("effort", "saved default", session.palette),
144
- options: EFFORTS.map((value) => ({ label: value })),
145
- 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)),
146
174
  });
147
- const value = index === undefined ? undefined : EFFORTS[index];
175
+ const value = index === undefined ? undefined : efforts[index];
148
176
  if (value === undefined || !(await persist(host, { effort: value })))
149
177
  return;
150
178
  session.config.effort = value;
151
179
  return value;
152
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
+ }
153
200
  async function motionSetting(session, host) {
154
201
  const choose = chooser(host);
155
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)
@@ -8,6 +8,7 @@ const MAX_RESULTS = 500;
8
8
  const MAX_VISITED = 20_000;
9
9
  const MAX_FILE_BYTES = 1_000_000;
10
10
  const MAX_MATCH_LINE = 500;
11
+ const MAX_GLOB_CHARS = 512;
11
12
  const SKIP = new Set([".git", ".hg", ".svn", "node_modules"]);
12
13
  export const findFiles = {
13
14
  name: "find_files",
@@ -17,7 +18,10 @@ export const findFiles = {
17
18
  input: {
18
19
  type: "object",
19
20
  properties: {
20
- pattern: { type: "string", description: "Glob matched against workspace-relative paths." },
21
+ pattern: {
22
+ type: "string",
23
+ description: "Glob matched against workspace-relative paths. Maximum 512 characters.",
24
+ },
21
25
  path: { type: "string", description: "Directory to search, relative to the workspace root." },
22
26
  max_results: { type: "integer", description: "Maximum paths returned. Defaults to 100, caps at 500." },
23
27
  },
@@ -52,7 +56,10 @@ export const searchText = {
52
56
  properties: {
53
57
  query: { type: "string", description: "Literal text to find." },
54
58
  path: { type: "string", description: "Directory to search, relative to the workspace root." },
55
- pattern: { type: "string", description: "Optional file glob, for example **/*.ts." },
59
+ pattern: {
60
+ type: "string",
61
+ description: "Optional file glob, for example **/*.ts. Maximum 512 characters.",
62
+ },
56
63
  case_sensitive: { type: "boolean", description: "Defaults to false." },
57
64
  max_results: { type: "integer", description: "Maximum matching lines. Defaults to 100, caps at 500." },
58
65
  },
@@ -166,29 +173,107 @@ function resultLimit(args) {
166
173
  }
167
174
  function glob(pattern) {
168
175
  const normalized = pattern.replace(/\\/g, "/");
169
- let source = "";
170
- for (let index = 0; index < normalized.length; index++) {
171
- const char = normalized[index];
172
- if (char === "*" && normalized[index + 1] === "*") {
173
- if (normalized[index + 2] === "/") {
174
- source += "(?:.*/)?";
175
- index += 2;
176
+ if (normalized.length > MAX_GLOB_CHARS) {
177
+ throw new Error(`"pattern" must be at most ${MAX_GLOB_CHARS} characters`);
178
+ }
179
+ const tokens = tokenizeGlob(normalized.toLowerCase());
180
+ const basenameOnly = !normalized.includes("/");
181
+ return (relative) => {
182
+ const candidate = relative.replace(/\\/g, "/");
183
+ const target = (basenameOnly ? path.posix.basename(candidate) : candidate).toLowerCase();
184
+ return matchGlob(tokens, Array.from(target));
185
+ };
186
+ }
187
+ function tokenizeGlob(pattern) {
188
+ const chars = Array.from(pattern);
189
+ const tokens = [];
190
+ let index = 0;
191
+ while (index < chars.length) {
192
+ const char = chars[index];
193
+ if (char === "*") {
194
+ let end = index + 1;
195
+ while (chars[end] === "*")
196
+ end++;
197
+ if (end - index >= 2) {
198
+ if (chars[end] === "/") {
199
+ tokens.push({ kind: "globdir-start" }, { kind: "globdir-body" });
200
+ index = end + 1;
201
+ }
202
+ else {
203
+ tokens.push({ kind: "globstar" });
204
+ index = end;
205
+ }
176
206
  }
177
207
  else {
178
- source += ".*";
179
- index++;
208
+ tokens.push({ kind: "star" });
209
+ index = end;
180
210
  }
211
+ continue;
181
212
  }
182
- else if (char === "*")
183
- source += "[^/]*";
184
- else if (char === "?")
185
- source += "[^/]";
186
- else
187
- source += char.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
213
+ tokens.push(char === "?" ? { kind: "one" } : { kind: "literal", value: char });
214
+ index++;
188
215
  }
189
- const expression = new RegExp(`^${source}$`, "i");
190
- const basenameOnly = !normalized.includes("/");
191
- return (relative) => expression.test(basenameOnly ? path.posix.basename(relative) : relative);
216
+ return tokens;
217
+ }
218
+ /** Thompson-style wildcard matching: O(pattern × path), with no regex backtracking. */
219
+ function matchGlob(tokens, text) {
220
+ let states = epsilonClosure(new Set([0]), tokens);
221
+ for (const char of text) {
222
+ const next = new Set();
223
+ for (const state of states) {
224
+ const token = tokens[state];
225
+ if (token === undefined)
226
+ continue;
227
+ switch (token.kind) {
228
+ case "literal":
229
+ if (token.value === char)
230
+ next.add(state + 1);
231
+ break;
232
+ case "one":
233
+ if (char !== "/")
234
+ next.add(state + 1);
235
+ break;
236
+ case "star":
237
+ if (char !== "/")
238
+ next.add(state);
239
+ break;
240
+ case "globstar":
241
+ next.add(state);
242
+ break;
243
+ case "globdir-body":
244
+ next.add(state);
245
+ if (char === "/")
246
+ next.add(state + 1);
247
+ break;
248
+ case "globdir-start":
249
+ break;
250
+ }
251
+ }
252
+ states = epsilonClosure(next, tokens);
253
+ if (states.size === 0)
254
+ return false;
255
+ }
256
+ return epsilonClosure(states, tokens).has(tokens.length);
257
+ }
258
+ function epsilonClosure(seed, tokens) {
259
+ const states = new Set(seed);
260
+ const pending = [...seed];
261
+ while (pending.length > 0) {
262
+ const state = pending.pop();
263
+ const token = tokens[state];
264
+ const targets = token?.kind === "globdir-start"
265
+ ? [state + 1, state + 2]
266
+ : token?.kind === "star" || token?.kind === "globstar"
267
+ ? [state + 1]
268
+ : [];
269
+ for (const target of targets) {
270
+ if (states.has(target))
271
+ continue;
272
+ states.add(target);
273
+ pending.push(target);
274
+ }
275
+ }
276
+ return states;
192
277
  }
193
278
  function summary(count, limit, capped, one, many) {
194
279
  const noun = count === 1 ? one : many;
@@ -4,7 +4,9 @@ import { spawn } from "node:child_process";
4
4
  import { optionalInt, requireString } from "./args.js";
5
5
  import { credentialRedactor, redactCredentials, shellEnvironment } from "../credential-safety.js";
6
6
  const DEFAULT_TIMEOUT_MS = 120_000;
7
+ const MAX_TIMEOUT_MS = 2_147_483_647;
7
8
  const MAX_OUTPUT_CHARS = 30_000;
9
+ const PIPE_DRAIN_MS = 100;
8
10
  export const runCommand = {
9
11
  name: "run_command",
10
12
  description: "Run a shell command starting in the workspace root and return its combined stdout " +
@@ -24,6 +26,9 @@ export const runCommand = {
24
26
  const timeoutMs = optionalInt(args, "timeout_ms") ?? DEFAULT_TIMEOUT_MS;
25
27
  if (timeoutMs <= 0)
26
28
  throw new Error('"timeout_ms" must be a positive integer');
29
+ if (timeoutMs > MAX_TIMEOUT_MS) {
30
+ throw new Error(`"timeout_ms" must be at most ${MAX_TIMEOUT_MS}ms`);
31
+ }
27
32
  const result = await execute(command, ctx.root, timeoutMs, ctx.signal, ctx.onOutput);
28
33
  const output = redactCredentials(result.output);
29
34
  const summary = result.timedOut
@@ -32,6 +37,7 @@ export const runCommand = {
32
37
  return {
33
38
  output: output === "" ? `[${summary}]` : `${output}\n[${summary}]`,
34
39
  summary,
40
+ isError: result.timedOut || result.code !== 0,
35
41
  };
36
42
  },
37
43
  };
@@ -45,12 +51,14 @@ function execute(command, cwd, timeoutMs, signal, onOutput) {
45
51
  shell: true,
46
52
  windowsHide: true,
47
53
  detached: process.platform !== "win32",
54
+ stdio: ["ignore", "pipe", "pipe"],
48
55
  });
49
56
  const output = capture(onOutput);
50
57
  let timedOut = false;
51
58
  let aborted;
52
59
  let settled = false;
53
60
  let forceTimer;
61
+ let drainTimer;
54
62
  const timer = setTimeout(() => {
55
63
  timedOut = true;
56
64
  stopTree(child.pid, false);
@@ -66,8 +74,20 @@ function execute(command, cwd, timeoutMs, signal, onOutput) {
66
74
  clearTimeout(timer);
67
75
  if (forceTimer !== undefined)
68
76
  clearTimeout(forceTimer);
77
+ if (drainTimer !== undefined)
78
+ clearTimeout(drainTimer);
69
79
  signal?.removeEventListener("abort", onAbort);
70
80
  };
81
+ const finish = (code) => {
82
+ if (settled)
83
+ return;
84
+ settled = true;
85
+ cleanup();
86
+ if (aborted !== undefined)
87
+ reject(aborted);
88
+ else
89
+ resolve({ output: output.value().trimEnd(), code, timedOut });
90
+ };
71
91
  child.stdout.setEncoding("utf8");
72
92
  child.stderr.setEncoding("utf8");
73
93
  child.stdout.on("data", output.append);
@@ -79,16 +99,17 @@ function execute(command, cwd, timeoutMs, signal, onOutput) {
79
99
  cleanup();
80
100
  reject(error);
81
101
  });
82
- child.on("close", (code) => {
83
- if (settled)
84
- return;
85
- settled = true;
86
- cleanup();
87
- if (aborted !== undefined)
88
- reject(aborted);
89
- else
90
- resolve({ output: output.value().trimEnd(), code, timedOut });
102
+ child.on("exit", (code) => {
103
+ // `close` normally follows once both pipes drain. A detached descendant
104
+ // can inherit those descriptors after the command itself has exited,
105
+ // though, so bound that final drain instead of hanging the tool on it.
106
+ drainTimer = setTimeout(() => {
107
+ child.stdout.destroy();
108
+ child.stderr.destroy();
109
+ finish(code);
110
+ }, PIPE_DRAIN_MS);
91
111
  });
112
+ child.on("close", finish);
92
113
  });
93
114
  }
94
115
  function capture(onOutput) {
package/dist/tui/app.js CHANGED
@@ -34,6 +34,7 @@ export async function runApp(session, transcriptRoot, environment = {}) {
34
34
  let closed;
35
35
  let frameTimer;
36
36
  let spinTimer;
37
+ let escapeTimer;
37
38
  let stopResize = () => { };
38
39
  let stopInput = () => { };
39
40
  // Timers outlive the teardown they were scheduled before. Painting after the
@@ -118,6 +119,8 @@ export async function runApp(session, transcriptRoot, environment = {}) {
118
119
  clearInterval(spinTimer);
119
120
  if (frameTimer !== undefined)
120
121
  clearTimeout(frameTimer);
122
+ if (escapeTimer !== undefined)
123
+ clearTimeout(escapeTimer);
121
124
  feedback.close();
122
125
  terminal.leave();
123
126
  closed?.();
@@ -204,12 +207,23 @@ export async function runApp(session, transcriptRoot, environment = {}) {
204
207
  draw();
205
208
  });
206
209
  stopInput = terminal.onInput((chunk) => {
207
- for (const key of keys.push(chunk))
210
+ if (escapeTimer !== undefined)
211
+ clearTimeout(escapeTimer);
212
+ for (const key of keys.push(chunk)) {
213
+ if (!live)
214
+ break;
208
215
  input.handle(key);
209
- setTimeout(() => {
216
+ }
217
+ if (!live)
218
+ return;
219
+ escapeTimer = setTimeout(() => {
220
+ escapeTimer = undefined;
221
+ if (!live)
222
+ return;
210
223
  for (const key of keys.flush())
211
224
  input.handle(key);
212
- render();
225
+ if (live)
226
+ render();
213
227
  }, ESCAPE_MS);
214
228
  render();
215
229
  });
@@ -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
+ }