@giovannijecha/jecode 0.1.5-rc.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.
Files changed (94) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +264 -0
  3. package/bin/jecode.js +11 -0
  4. package/dist/atomic.js +78 -0
  5. package/dist/batch-view.js +17 -0
  6. package/dist/batch.js +100 -0
  7. package/dist/cli-info.js +40 -0
  8. package/dist/commands.js +171 -0
  9. package/dist/config.js +97 -0
  10. package/dist/controller.js +90 -0
  11. package/dist/credential-commands.js +134 -0
  12. package/dist/credential-safety.js +86 -0
  13. package/dist/credentials.js +124 -0
  14. package/dist/main.js +7 -0
  15. package/dist/ollama-settings-command.js +75 -0
  16. package/dist/prompt.js +29 -0
  17. package/dist/provider-commands.js +236 -0
  18. package/dist/provider-errors.js +10 -0
  19. package/dist/providers/anthropic-stream.js +111 -0
  20. package/dist/providers/anthropic-wire.js +79 -0
  21. package/dist/providers/anthropic.js +77 -0
  22. package/dist/providers/catalog.js +37 -0
  23. package/dist/providers/http.js +219 -0
  24. package/dist/providers/index.js +18 -0
  25. package/dist/providers/ollama-endpoint.js +40 -0
  26. package/dist/providers/ollama-stream.js +60 -0
  27. package/dist/providers/ollama-wire.js +95 -0
  28. package/dist/providers/ollama.js +87 -0
  29. package/dist/providers/openai-stream.js +48 -0
  30. package/dist/providers/openai-wire.js +112 -0
  31. package/dist/providers/openai.js +70 -0
  32. package/dist/providers/sse.js +81 -0
  33. package/dist/providers/stream-limits.js +11 -0
  34. package/dist/session.js +3 -0
  35. package/dist/settings-command.js +202 -0
  36. package/dist/settings.js +98 -0
  37. package/dist/start.js +47 -0
  38. package/dist/tools/args.js +38 -0
  39. package/dist/tools/fs.js +277 -0
  40. package/dist/tools/index.js +39 -0
  41. package/dist/tools/paths.js +122 -0
  42. package/dist/tools/search.js +213 -0
  43. package/dist/tools/shell.js +139 -0
  44. package/dist/tools/text-boundary.js +102 -0
  45. package/dist/tools/types.js +1 -0
  46. package/dist/transcript-export.js +11 -0
  47. package/dist/transcript.js +49 -0
  48. package/dist/tui/activity.js +11 -0
  49. package/dist/tui/app-input.js +155 -0
  50. package/dist/tui/app-state.js +17 -0
  51. package/dist/tui/app-workflows.js +105 -0
  52. package/dist/tui/app.js +215 -0
  53. package/dist/tui/approve.js +67 -0
  54. package/dist/tui/blocks.js +23 -0
  55. package/dist/tui/complete.js +54 -0
  56. package/dist/tui/components/command-menu.js +17 -0
  57. package/dist/tui/components/composer.js +47 -0
  58. package/dist/tui/components/dock.js +10 -0
  59. package/dist/tui/components/footer.js +21 -0
  60. package/dist/tui/components/menu.js +38 -0
  61. package/dist/tui/components/messages.js +43 -0
  62. package/dist/tui/components/misc.js +22 -0
  63. package/dist/tui/components/status.js +45 -0
  64. package/dist/tui/components/tool.js +85 -0
  65. package/dist/tui/components/types.js +1 -0
  66. package/dist/tui/editor.js +105 -0
  67. package/dist/tui/feedback.js +74 -0
  68. package/dist/tui/field.js +60 -0
  69. package/dist/tui/frame.js +33 -0
  70. package/dist/tui/input.js +52 -0
  71. package/dist/tui/keys.js +177 -0
  72. package/dist/tui/modal.js +24 -0
  73. package/dist/tui/overlay.js +93 -0
  74. package/dist/tui/picker.js +99 -0
  75. package/dist/tui/screen.js +94 -0
  76. package/dist/tui/scroll.js +7 -0
  77. package/dist/tui/session-view.js +49 -0
  78. package/dist/tui/transcript-view.js +134 -0
  79. package/dist/tui/turn.js +255 -0
  80. package/dist/tui/view.js +88 -0
  81. package/dist/tui/workspace.js +59 -0
  82. package/dist/types.js +9 -0
  83. package/dist/ui/diff.js +109 -0
  84. package/dist/ui/highlight.js +158 -0
  85. package/dist/ui/inline.js +39 -0
  86. package/dist/ui/markdown.js +147 -0
  87. package/dist/ui/render.js +232 -0
  88. package/dist/ui/table.js +127 -0
  89. package/dist/ui/terminal-text.js +42 -0
  90. package/dist/ui/theme.js +25 -0
  91. package/dist/ui/width.js +196 -0
  92. package/dist/usage.js +30 -0
  93. package/dist/user-data.js +29 -0
  94. package/package.json +56 -0
@@ -0,0 +1,48 @@
1
+ // Reassembling an OpenAI Responses reply from its event stream.
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.
7
+ export async function assembleOpenAI(events, onStream) {
8
+ const items = [];
9
+ let completed;
10
+ let refusal = false;
11
+ for await (const raw of events) {
12
+ const event = raw;
13
+ switch (event.type) {
14
+ case "response.output_text.delta":
15
+ if (typeof event.delta === "string")
16
+ onStream?.({ kind: "text", text: event.delta });
17
+ break;
18
+ case "response.refusal.delta":
19
+ if (typeof event.delta === "string") {
20
+ onStream?.({ kind: "text", text: `${refusal ? "" : "[refused] "}${event.delta}` });
21
+ refusal = true;
22
+ }
23
+ break;
24
+ case "response.reasoning_summary_text.delta":
25
+ if (typeof event.delta === "string")
26
+ onStream?.({ kind: "thinking", text: event.delta });
27
+ break;
28
+ case "response.output_item.done":
29
+ if (event.item !== undefined)
30
+ items.push(event.item);
31
+ break;
32
+ case "response.completed":
33
+ case "response.incomplete":
34
+ if (event.response !== undefined)
35
+ completed = event.response;
36
+ break;
37
+ case "response.failed": {
38
+ const response = event.response;
39
+ throw new Error(`openai stream error: ${response?.error?.message ?? "unspecified"}`);
40
+ }
41
+ case "error":
42
+ throw new Error(`openai stream error: ${event.error?.message ?? event.message ?? "unspecified"}`);
43
+ default:
44
+ break;
45
+ }
46
+ }
47
+ return completed ?? { output: items };
48
+ }
@@ -0,0 +1,112 @@
1
+ // Translation between the normalized vocabulary and the OpenAI Responses wire
2
+ // shape: a flat `input` list where tool calls and their outputs are top-level
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
+ export function toWireTool(tool) {
10
+ return {
11
+ type: "function",
12
+ name: tool.name,
13
+ description: tool.description,
14
+ parameters: tool.input,
15
+ };
16
+ }
17
+ export function toWireItems(message) {
18
+ if (message.rawFrom === "openai" && Array.isArray(message.raw)) {
19
+ return message.raw;
20
+ }
21
+ const items = [];
22
+ const texts = [];
23
+ for (const block of message.content) {
24
+ if (block.kind === "text") {
25
+ texts.push(block.text);
26
+ }
27
+ else if (block.kind === "tool_call") {
28
+ items.push({
29
+ type: "function_call",
30
+ call_id: block.id,
31
+ name: block.name,
32
+ arguments: JSON.stringify(block.input),
33
+ });
34
+ }
35
+ else {
36
+ items.push({ type: "function_call_output", call_id: block.id, output: block.output });
37
+ }
38
+ }
39
+ if (texts.length > 0) {
40
+ const type = message.role === "assistant" ? "output_text" : "input_text";
41
+ items.unshift({ role: message.role, content: [{ type, text: texts.join("\n") }] });
42
+ }
43
+ return items;
44
+ }
45
+ export function stopNotice(data) {
46
+ const reason = data.incomplete_details?.reason;
47
+ if (reason === undefined)
48
+ return undefined;
49
+ return reason === "max_output_tokens"
50
+ ? "[truncated: hit max_output_tokens — raise --max-tokens]"
51
+ : `[incomplete: ${reason}]`;
52
+ }
53
+ export function fromWireResponse(data) {
54
+ const raw = Array.isArray(data.output) ? data.output : [];
55
+ const content = [];
56
+ for (const entry of raw) {
57
+ const item = entry;
58
+ if (item.type === "message" && Array.isArray(item.content)) {
59
+ for (const part of item.content) {
60
+ const piece = part;
61
+ if (piece.type === "output_text" && typeof piece.text === "string") {
62
+ content.push({ kind: "text", text: piece.text });
63
+ }
64
+ else if (piece.type === "refusal" && typeof piece.refusal === "string") {
65
+ content.push({ kind: "text", text: `[refused] ${piece.refusal}` });
66
+ }
67
+ }
68
+ }
69
+ else if (item.type === "function_call" &&
70
+ typeof item.call_id === "string" &&
71
+ typeof item.name === "string") {
72
+ content.push({
73
+ kind: "tool_call",
74
+ id: item.call_id,
75
+ name: item.name,
76
+ input: parseArguments(item.arguments),
77
+ });
78
+ }
79
+ // reasoning items and anything future: carried in `raw` only.
80
+ }
81
+ const notice = stopNotice(data);
82
+ if (notice !== undefined)
83
+ content.push({ kind: "text", text: notice });
84
+ return { role: "assistant", content, raw, rawFrom: "openai", usage: normalizeUsage(data) };
85
+ }
86
+ function normalizeUsage(data) {
87
+ const usage = data.usage;
88
+ if (usage === undefined || usage === null)
89
+ return undefined;
90
+ return {
91
+ inputTokens: usage.input_tokens ?? 0,
92
+ outputTokens: usage.output_tokens ?? 0,
93
+ cachedInputTokens: usage.input_tokens_details?.cached_tokens ?? 0,
94
+ cacheWriteInputTokens: usage.input_tokens_details?.cache_write_tokens ?? 0,
95
+ reasoningTokens: usage.output_tokens_details?.reasoning_tokens ?? 0,
96
+ };
97
+ }
98
+ // Arguments arrive as a JSON string and models vary in how they escape it, so
99
+ // this always goes through a real parse — never string matching.
100
+ function parseArguments(text) {
101
+ if (text === undefined || text === "")
102
+ return {};
103
+ try {
104
+ const parsed = JSON.parse(text);
105
+ return typeof parsed === "object" && parsed !== null
106
+ ? parsed
107
+ : {};
108
+ }
109
+ catch {
110
+ return {};
111
+ }
112
+ }
@@ -0,0 +1,70 @@
1
+ // OpenAI Responses API, spoken directly.
2
+ //
3
+ // Responses wire contract verified against the official API reference on
4
+ // 2026-08-29. Keep final response events authoritative over display deltas.
5
+ import { postSse } from "./http.js";
6
+ import { listModels } from "./catalog.js";
7
+ import { keyFor } from "../credentials.js";
8
+ import { assembleOpenAI } from "./openai-stream.js";
9
+ import { fromWireResponse, normalizeEffort, stopNotice, toWireItems, toWireTool, } from "./openai-wire.js";
10
+ const ENDPOINT = "https://api.openai.com/v1/responses";
11
+ const MODELS = "https://api.openai.com/v1/models";
12
+ 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)(?:[-_]|$)/;
23
+ export const openai = {
24
+ id: "openai",
25
+ defaultModel: "gpt-5",
26
+ keyVar: KEY,
27
+ blocked() {
28
+ return apiKey() === undefined ? `${KEY} is not set` : undefined;
29
+ },
30
+ // The endpoint answers in no order worth keeping, so descending puts the
31
+ // highest-numbered family — usually the newest — at the top of the menu.
32
+ async models(signal, onStatus) {
33
+ const ids = await listModels(MODELS, headers(requireKey()), signal, onStatus);
34
+ return ids
35
+ .filter((id) => !NOT_CHAT.test(id) && !NON_TEXT_MODE.test(id))
36
+ .sort((a, b) => b.localeCompare(a));
37
+ },
38
+ location: () => "cloud",
39
+ async send(req) {
40
+ const key = requireKey();
41
+ const events = await postSse(ENDPOINT, headers(key), {
42
+ model: req.model,
43
+ instructions: req.system,
44
+ input: req.messages.flatMap(toWireItems),
45
+ tools: req.tools.map(toWireTool),
46
+ max_output_tokens: req.maxTokens,
47
+ reasoning: { effort: normalizeEffort(req.effort), summary: "auto" },
48
+ store: false,
49
+ include: ["reasoning.encrypted_content"],
50
+ stream: true,
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);
57
+ },
58
+ };
59
+ function apiKey() {
60
+ return keyFor(KEY);
61
+ }
62
+ function requireKey() {
63
+ const key = apiKey();
64
+ if (key === undefined)
65
+ throw new Error(`${KEY} is not set`);
66
+ return key;
67
+ }
68
+ function headers(key) {
69
+ return { authorization: `Bearer ${key}` };
70
+ }
@@ -0,0 +1,81 @@
1
+ // Server-sent events, read off a fetch response body.
2
+ //
3
+ // The format is small: `field: value` lines, a blank line ends an event. Only
4
+ // `data` matters here — both providers put the event discriminator inside the
5
+ // JSON payload, so the `event:` line is redundant and skipped.
6
+ import { addBounded, MAX_SSE_EVENT_CHARS, MAX_SSE_STREAM_CHARS, } from "./stream-limits.js";
7
+ export async function* readSseJson(body) {
8
+ const reader = body.getReader();
9
+ const decoder = new TextDecoder();
10
+ let buffer = "";
11
+ let finished = false;
12
+ let total = 0;
13
+ const append = (text) => {
14
+ total = addBounded(total, text.length, MAX_SSE_STREAM_CHARS, "SSE stream");
15
+ buffer += text;
16
+ };
17
+ try {
18
+ for (;;) {
19
+ const { done, value } = await reader.read();
20
+ if (done)
21
+ break;
22
+ append(decoder.decode(value, { stream: true }));
23
+ for (;;) {
24
+ const boundary = findBoundary(buffer);
25
+ if (boundary === undefined)
26
+ break;
27
+ assertEventSize(boundary.start);
28
+ const chunk = buffer.slice(0, boundary.start);
29
+ buffer = buffer.slice(boundary.end);
30
+ const payload = parseData(chunk);
31
+ if (payload !== undefined)
32
+ yield payload;
33
+ }
34
+ assertEventSize(buffer.length);
35
+ }
36
+ // A stream that ends without a trailing blank line still owes us its last
37
+ // event.
38
+ append(decoder.decode());
39
+ assertEventSize(buffer.length);
40
+ const payload = parseData(buffer);
41
+ if (payload !== undefined)
42
+ yield payload;
43
+ finished = true;
44
+ }
45
+ finally {
46
+ if (!finished)
47
+ await reader.cancel().catch(() => undefined);
48
+ reader.releaseLock();
49
+ }
50
+ }
51
+ function assertEventSize(length) {
52
+ if (length > MAX_SSE_EVENT_CHARS) {
53
+ throw new Error(`SSE event exceeded ${MAX_SSE_EVENT_CHARS} characters`);
54
+ }
55
+ }
56
+ // Handles both LF and CRLF framing without normalising the buffer first — a
57
+ // normalising pass would have to cope with a \r\n split across two chunks.
58
+ function findBoundary(buffer) {
59
+ const lf = buffer.indexOf("\n\n");
60
+ const crlf = buffer.indexOf("\r\n\r\n");
61
+ if (lf === -1 && crlf === -1)
62
+ return undefined;
63
+ if (crlf !== -1 && (lf === -1 || crlf < lf))
64
+ return { start: crlf, end: crlf + 4 };
65
+ return { start: lf, end: lf + 2 };
66
+ }
67
+ function parseData(chunk) {
68
+ const data = chunk
69
+ .split(/\r?\n/)
70
+ .filter((line) => line.startsWith("data:"))
71
+ .map((line) => line.slice("data:".length).trimStart())
72
+ .join("\n");
73
+ if (data === "" || data === "[DONE]")
74
+ return undefined;
75
+ try {
76
+ return JSON.parse(data);
77
+ }
78
+ catch {
79
+ return undefined;
80
+ }
81
+ }
@@ -0,0 +1,11 @@
1
+ // Response streams are remote input. Bound the pieces that otherwise grow
2
+ // independently of the request's output-token setting.
3
+ export const MAX_SSE_EVENT_CHARS = 1_000_000;
4
+ export const MAX_SSE_STREAM_CHARS = 4_000_000;
5
+ export const MAX_TOOL_ARGUMENT_CHARS = 1_000_000;
6
+ export function addBounded(total, added, maximum, label) {
7
+ if (added > maximum - total) {
8
+ throw new Error(`${label} exceeded ${maximum} characters`);
9
+ }
10
+ return total + added;
11
+ }
@@ -0,0 +1,3 @@
1
+ // What one run holds. Its own module so the app and the slash commands can
2
+ // both depend on the shape without depending on each other.
3
+ export {};
@@ -0,0 +1,202 @@
1
+ // The persistent settings hub. Every interaction uses the shared dock picker
2
+ // and field contracts; this module owns choices and persistence, not drawing.
3
+ import { modelsCommand, providersCommand } from "./provider-commands.js";
4
+ import { credentialsCommand } from "./credential-commands.js";
5
+ import { EFFORTS, readSettings, settingsLabel, updateSettings } from "./settings.js";
6
+ import { ollamaConnectionHint, ollamaConnectionSetting, } from "./ollama-settings-command.js";
7
+ import { of } from "./tui/editor.js";
8
+ import { heading } from "./tui/picker.js";
9
+ /** A focused path to the same saved reasoning default exposed by /settings. */
10
+ export async function effortCommand(session, host) {
11
+ const value = await effortSetting(session, host);
12
+ if (value === undefined)
13
+ return;
14
+ host.emit({ kind: "notice", text: `effort · ${value}`, tone: "info" });
15
+ }
16
+ export async function settingsCommand(session, host) {
17
+ const choose = chooser(host);
18
+ if (choose === undefined)
19
+ return;
20
+ let selected = 0;
21
+ while (true) {
22
+ const values = settingsValues(session);
23
+ const items = settingsItems(values);
24
+ const index = await choose(settingsPicker(values, session.palette, selected));
25
+ if (index === undefined)
26
+ return;
27
+ const action = items[index]?.action;
28
+ if (action === undefined || action === "close")
29
+ return;
30
+ selected = index;
31
+ switch (action) {
32
+ case "provider":
33
+ await providerSetting(session, host);
34
+ break;
35
+ case "ollamaConnection":
36
+ await ollamaConnectionSetting(session, host, (patch) => persist(host, patch));
37
+ break;
38
+ case "model":
39
+ await modelSetting(session, host);
40
+ break;
41
+ case "effort":
42
+ await effortSetting(session, host);
43
+ break;
44
+ case "maxTokens":
45
+ await numberSetting(session, host, "maxTokens", "max output tokens");
46
+ break;
47
+ case "maxSteps":
48
+ await numberSetting(session, host, "maxSteps", "max tool steps");
49
+ break;
50
+ case "reducedMotion":
51
+ await motionSetting(session, host);
52
+ break;
53
+ case "credentials":
54
+ await credentialsCommand(session, host);
55
+ break;
56
+ }
57
+ }
58
+ }
59
+ export function settingsPicker(values, pal, index = 0, store = settingsLabel()) {
60
+ return {
61
+ title: heading("settings", store, pal),
62
+ right: "↑↓ enter · esc close",
63
+ footer: "Changes apply now · flags and environment win at launch",
64
+ options: settingsItems(values).map((item) => item.option),
65
+ index,
66
+ };
67
+ }
68
+ function settingsItems(values) {
69
+ return [
70
+ { action: "provider", option: { label: "provider", hint: values.provider } },
71
+ ...(values.ollamaConnection === undefined
72
+ ? []
73
+ : [{
74
+ action: "ollamaConnection",
75
+ option: { label: "ollama connection", hint: values.ollamaConnection },
76
+ }]),
77
+ { action: "model", option: { label: "model", hint: values.model || "choose a model" } },
78
+ { action: "effort", option: { label: "effort", hint: values.effort } },
79
+ { action: "maxTokens", option: { label: "max output tokens", hint: String(values.maxTokens) } },
80
+ { action: "maxSteps", option: { label: "max tool steps", hint: String(values.maxSteps) } },
81
+ {
82
+ action: "reducedMotion",
83
+ option: { label: "reduced motion", hint: values.reducedMotion ? "on" : "off" },
84
+ },
85
+ { action: "credentials", option: { label: "credentials", hint: "manage API keys" } },
86
+ { action: "close", option: { label: "close" } },
87
+ ];
88
+ }
89
+ function settingsValues(session) {
90
+ return {
91
+ provider: session.provider.id,
92
+ model: session.model,
93
+ ...(session.provider.id === "ollama" ? { ollamaConnection: ollamaConnectionHint() } : {}),
94
+ effort: session.config.effort,
95
+ maxTokens: session.config.maxTokens,
96
+ maxSteps: session.config.maxSteps,
97
+ reducedMotion: session.config.reducedMotion,
98
+ };
99
+ }
100
+ async function providerSetting(session, host) {
101
+ const before = {
102
+ provider: session.provider,
103
+ model: session.model,
104
+ providerId: session.config.providerId,
105
+ configModel: session.config.model,
106
+ };
107
+ if (!(await providersCommand(session, host, { announce: false, save: false })))
108
+ return;
109
+ const current = readSettings();
110
+ const models = { ...current.models };
111
+ if (session.model !== "")
112
+ models[session.provider.id] = session.model;
113
+ if (await persist(host, { provider: session.provider.id, models }))
114
+ return;
115
+ session.provider = before.provider;
116
+ session.model = before.model;
117
+ session.config.providerId = before.providerId;
118
+ session.config.model = before.configModel;
119
+ }
120
+ async function modelSetting(session, host) {
121
+ const before = { model: session.model, configModel: session.config.model };
122
+ if (!(await modelsCommand(session, host, { announce: false, save: false })))
123
+ return;
124
+ const current = readSettings();
125
+ const models = { ...current.models, [session.provider.id]: session.model };
126
+ if (await persist(host, { models }))
127
+ return;
128
+ session.model = before.model;
129
+ session.config.model = before.configModel;
130
+ }
131
+ async function effortSetting(session, host) {
132
+ const choose = chooser(host);
133
+ if (choose === undefined)
134
+ return;
135
+ const current = session.config.effort;
136
+ const index = await choose({
137
+ title: heading("effort", "saved default", session.palette),
138
+ right: "↑↓ enter · esc back",
139
+ options: EFFORTS.map((value) => ({ label: value })),
140
+ index: Math.max(0, EFFORTS.findIndex((value) => value === current)),
141
+ });
142
+ const value = index === undefined ? undefined : EFFORTS[index];
143
+ if (value === undefined || !(await persist(host, { effort: value })))
144
+ return;
145
+ session.config.effort = value;
146
+ return value;
147
+ }
148
+ async function motionSetting(session, host) {
149
+ const choose = chooser(host);
150
+ if (choose === undefined)
151
+ return;
152
+ const values = [false, true];
153
+ const index = await choose({
154
+ title: heading("reduced motion", "saved default", session.palette),
155
+ right: "↑↓ enter · esc back",
156
+ options: values.map((value) => ({ label: value ? "on" : "off" })),
157
+ index: session.config.reducedMotion ? 1 : 0,
158
+ });
159
+ const value = index === undefined ? undefined : values[index];
160
+ if (value === undefined || !(await persist(host, { reducedMotion: value })))
161
+ return;
162
+ session.config.reducedMotion = value;
163
+ host.refreshSettings?.();
164
+ }
165
+ async function numberSetting(session, host, name, label) {
166
+ if (host.type === undefined)
167
+ return;
168
+ const field = {
169
+ title: heading(label, "positive integer", session.palette),
170
+ right: "enter save · esc back",
171
+ editor: of(String(session.config[name])),
172
+ secret: false,
173
+ note: "Applies to the next model turn.",
174
+ };
175
+ const text = await host.type(field);
176
+ if (text === undefined)
177
+ return;
178
+ const value = Number(text);
179
+ if (!Number.isSafeInteger(value) || value <= 0) {
180
+ host.emit({ kind: "notice", text: `${label} must be a positive integer`, tone: "error" });
181
+ return;
182
+ }
183
+ if (!(await persist(host, { [name]: value })))
184
+ return;
185
+ session.config[name] = value;
186
+ }
187
+ async function persist(host, patch) {
188
+ try {
189
+ await updateSettings(patch);
190
+ return true;
191
+ }
192
+ catch (error) {
193
+ host.emit({ kind: "notice", text: `could not save settings: ${error.message}`, tone: "error" });
194
+ return false;
195
+ }
196
+ }
197
+ function chooser(host) {
198
+ if (host.choose === undefined) {
199
+ host.emit({ kind: "notice", text: "that command needs the screen", tone: "warn" });
200
+ }
201
+ return host.choose;
202
+ }
@@ -0,0 +1,98 @@
1
+ // Persistent, non-secret defaults for interactive and batch sessions.
2
+ import { readFileSync } from "node:fs";
3
+ import { chmod, mkdir } from "node:fs/promises";
4
+ import * as path from "node:path";
5
+ import { atomicWrite } from "./atomic.js";
6
+ import { providerNames } from "./providers/index.js";
7
+ import { parseOllamaEndpoint } from "./providers/ollama-endpoint.js";
8
+ import { userDataLabel, userDataPath } from "./user-data.js";
9
+ export const EFFORTS = ["low", "medium", "high", "xhigh", "max"];
10
+ let saved;
11
+ export function readSettings() {
12
+ if (saved === undefined)
13
+ saved = readStore();
14
+ return copy(saved);
15
+ }
16
+ export async function updateSettings(patch) {
17
+ const next = normalize({ ...readSettings(), ...patch });
18
+ const file = settingsPath();
19
+ const directory = path.dirname(file);
20
+ await mkdir(directory, { recursive: true, mode: 0o700 });
21
+ if (process.platform !== "win32")
22
+ await chmod(directory, 0o700);
23
+ await atomicWrite(file, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
24
+ saved = next;
25
+ return file;
26
+ }
27
+ export function settingsPath() {
28
+ return userDataPath("settings.json");
29
+ }
30
+ export function settingsLabel() {
31
+ return userDataLabel("settings.json");
32
+ }
33
+ /** Forget the cached read so tests and explicit reloads see the disk again. */
34
+ export function reloadSettings() {
35
+ saved = undefined;
36
+ }
37
+ function readStore() {
38
+ try {
39
+ return normalize(JSON.parse(readFileSync(settingsPath(), "utf8")));
40
+ }
41
+ catch {
42
+ // Missing, unreadable, and malformed stores all fall back safely. A bad
43
+ // preference must never prevent the agent from starting.
44
+ return {};
45
+ }
46
+ }
47
+ function normalize(value) {
48
+ if (!record(value))
49
+ return {};
50
+ const providers = providerNames();
51
+ const provider = member(value["provider"], providers);
52
+ const models = modelsOf(value["models"], providers);
53
+ const ollamaHost = endpoint(value["ollamaHost"]);
54
+ const effort = member(value["effort"], EFFORTS);
55
+ const reducedMotion = typeof value["reducedMotion"] === "boolean" ? value["reducedMotion"] : undefined;
56
+ const maxTokens = positiveInteger(value["maxTokens"]);
57
+ const maxSteps = positiveInteger(value["maxSteps"]);
58
+ return {
59
+ ...(provider === undefined ? {} : { provider }),
60
+ ...(models === undefined ? {} : { models }),
61
+ ...(ollamaHost === undefined ? {} : { ollamaHost }),
62
+ ...(effort === undefined ? {} : { effort }),
63
+ ...(reducedMotion === undefined ? {} : { reducedMotion }),
64
+ ...(maxTokens === undefined ? {} : { maxTokens }),
65
+ ...(maxSteps === undefined ? {} : { maxSteps }),
66
+ };
67
+ }
68
+ function modelsOf(value, providers) {
69
+ if (!record(value))
70
+ return undefined;
71
+ const models = Object.fromEntries(Object.entries(value).filter((entry) => providers.includes(entry[0]) && typeof entry[1] === "string" && entry[1].trim() !== ""));
72
+ return Object.keys(models).length === 0 ? undefined : models;
73
+ }
74
+ function member(value, values) {
75
+ return typeof value === "string" && values.includes(value) ? value : undefined;
76
+ }
77
+ function endpoint(value) {
78
+ if (typeof value !== "string")
79
+ return undefined;
80
+ try {
81
+ return parseOllamaEndpoint(value).baseUrl;
82
+ }
83
+ catch {
84
+ return undefined;
85
+ }
86
+ }
87
+ function positiveInteger(value) {
88
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined;
89
+ }
90
+ function record(value) {
91
+ return typeof value === "object" && value !== null && !Array.isArray(value);
92
+ }
93
+ function copy(value) {
94
+ return {
95
+ ...value,
96
+ ...(value.models === undefined ? {} : { models: { ...value.models } }),
97
+ };
98
+ }
package/dist/start.js ADDED
@@ -0,0 +1,47 @@
1
+ // Application bootstrap: resolve one session, then choose its terminal surface.
2
+ import * as path from "node:path";
3
+ import { runBatch } from "./batch.js";
4
+ import { showCliInfo } from "./cli-info.js";
5
+ import { loadConfig } from "./config.js";
6
+ import { systemPrompt } from "./prompt.js";
7
+ import { configureProviders, selectProvider } from "./providers/index.js";
8
+ import { builtinTools } from "./tools/index.js";
9
+ import { configureColor } from "./ui/render.js";
10
+ import { STEEL } from "./ui/theme.js";
11
+ import { emptyUsage } from "./usage.js";
12
+ import { runApp } from "./tui/app.js";
13
+ import { interactive } from "./tui/screen.js";
14
+ export async function start(args = process.argv.slice(2), environment = {}) {
15
+ const applicationRoot = environment.applicationRoot ?? path.resolve(import.meta.dirname, "..");
16
+ const transcriptRoot = environment.transcriptRoot ?? process.cwd();
17
+ const write = environment.write ?? ((text) => process.stdout.write(text));
18
+ if (await showCliInfo(args, applicationRoot, write))
19
+ return;
20
+ const config = loadConfig(args);
21
+ configureProviders(config);
22
+ const provider = selectProvider(config.providerId);
23
+ const hasScreen = environment.interactive?.() ?? interactive();
24
+ // A provider whose catalogue is not fixed has no sensible default model.
25
+ // The TUI can ask; a pipe cannot, so batch mode still requires one up front.
26
+ const model = config.model === "" ? provider.defaultModel : config.model;
27
+ if (model === "" && !hasScreen) {
28
+ throw new Error(`${provider.id} has no default model — pass --model <id> (or set JECODE_MODEL)`);
29
+ }
30
+ configureColor(hasScreen);
31
+ const session = {
32
+ config,
33
+ provider,
34
+ model,
35
+ palette: STEEL,
36
+ tools: builtinTools(),
37
+ system: systemPrompt(config),
38
+ history: [],
39
+ usage: emptyUsage(),
40
+ };
41
+ if (hasScreen) {
42
+ await (environment.runInteractive ?? runApp)(session, transcriptRoot);
43
+ }
44
+ else {
45
+ await (environment.runNonInteractive ?? runBatch)(session);
46
+ }
47
+ }