@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.
- package/README.md +4 -3
- package/dist/controller.js +63 -14
- package/dist/credential-safety.js +19 -1
- package/dist/effort.js +25 -0
- package/dist/openai-account.js +10 -7
- package/dist/provider-commands.js +67 -14
- package/dist/providers/anthropic-stream.js +8 -2
- package/dist/providers/anthropic.js +21 -5
- package/dist/providers/ollama-stream.js +3 -0
- package/dist/providers/ollama.js +3 -0
- package/dist/providers/openai-codex.js +53 -10
- package/dist/providers/openai-stream.js +1 -1
- package/dist/providers/openai-wire.js +0 -5
- package/dist/providers/openai.js +34 -13
- package/dist/settings-command.js +53 -6
- package/dist/settings.js +2 -1
- package/dist/tools/args.js +4 -3
- package/dist/tools/fs.js +8 -3
- package/dist/tools/index.js +2 -2
- package/dist/tools/search.js +105 -20
- package/dist/tools/shell.js +30 -9
- package/dist/tui/app.js +17 -3
- package/dist/tui/components/messages.js +26 -5
- package/dist/tui/keys.js +33 -0
- package/dist/tui/screen.js +19 -5
- package/dist/tui/turn.js +4 -1
- package/dist/ui/width.js +41 -1
- package/package.json +1 -1
package/dist/providers/openai.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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(
|
|
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
|
|
68
|
+
reasoning: { effort, summary: "auto" },
|
|
48
69
|
store: false,
|
|
49
70
|
include: ["reasoning.encrypted_content"],
|
|
50
71
|
stream: true,
|
package/dist/settings-command.js
CHANGED
|
@@ -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
|
-
|
|
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 = {
|
|
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
|
-
|
|
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:
|
|
145
|
-
index: Math.max(0,
|
|
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 :
|
|
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
|
|
10
|
+
export { EFFORTS } from "./effort.js";
|
|
10
11
|
let saved;
|
|
11
12
|
export function readSettings() {
|
|
12
13
|
if (saved === undefined)
|
package/dist/tools/args.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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) {
|
package/dist/tools/index.js
CHANGED
|
@@ -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
|
|
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)
|
package/dist/tools/search.js
CHANGED
|
@@ -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: {
|
|
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: {
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
179
|
-
index
|
|
208
|
+
tokens.push({ kind: "star" });
|
|
209
|
+
index = end;
|
|
180
210
|
}
|
|
211
|
+
continue;
|
|
181
212
|
}
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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;
|
package/dist/tools/shell.js
CHANGED
|
@@ -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("
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
25
|
-
|
|
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
|
-
:
|
|
30
|
-
? "
|
|
31
|
-
:
|
|
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
|
+
}
|