@giovannijecha/jecode 0.8.6 → 0.8.7
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 +18 -8
- package/assets/tokenizers/LICENSE +21 -0
- package/assets/tokenizers/o200k-base.tiktoken.gz +0 -0
- package/dist/cli-info.js +8 -13
- package/dist/commands.js +7 -3
- package/dist/config.js +33 -47
- package/dist/context/automatic.js +16 -1
- package/dist/context/budget.js +5 -2
- package/dist/context/compactor.js +91 -37
- package/dist/context/diagnostics.js +108 -0
- package/dist/context/lifetime.js +18 -0
- package/dist/context/manager.js +67 -0
- package/dist/context/manual.js +11 -10
- package/dist/context/measurement.js +75 -0
- package/dist/context/policy.js +13 -10
- package/dist/context/request-observation.js +46 -0
- package/dist/context/request-projection.js +8 -34
- package/dist/context/request.js +22 -0
- package/dist/context/tokenizer/bpe.js +68 -0
- package/dist/context/tokenizer/o200k.js +54 -0
- package/dist/context/tokenizer/vocabulary.js +34 -0
- package/dist/controller-request.js +47 -45
- package/dist/controller.js +13 -2
- package/dist/input-boundary.js +0 -62
- package/dist/launch.js +13 -9
- package/dist/model-command.js +7 -17
- package/dist/oauth-result-page.js +68 -0
- package/dist/openai-oauth-callback.js +2 -50
- package/dist/permission-command.js +9 -24
- package/dist/permissions.js +10 -8
- package/dist/providers/anthropic-wire.js +1 -1
- package/dist/providers/anthropic.js +3 -1
- package/dist/providers/input-measurement.js +85 -0
- package/dist/providers/ollama-wire.js +1 -1
- package/dist/providers/ollama.js +2 -0
- package/dist/providers/openai-codex.js +3 -0
- package/dist/providers/openai-stream.js +20 -8
- package/dist/providers/openai-summary.js +37 -0
- package/dist/providers/openai-wire.js +1 -1
- package/dist/providers/openai.js +3 -0
- package/dist/settings-command.js +5 -10
- package/dist/settings.js +1 -1
- package/dist/start.js +35 -51
- package/dist/timeline.js +2 -0
- package/dist/tui/activity.js +1 -1
- package/dist/tui/app-input.js +34 -17
- package/dist/tui/app-workflows.js +7 -2
- package/dist/tui/app.js +55 -28
- package/dist/tui/approve.js +3 -4
- package/dist/tui/blocks.js +5 -7
- package/dist/tui/command-workflow.js +14 -8
- package/dist/tui/components/command-menu.js +1 -1
- package/dist/tui/components/menu.js +19 -11
- package/dist/tui/components/status.js +1 -1
- package/dist/tui/frame.js +8 -3
- package/dist/tui/help.js +2 -1
- package/dist/tui/picker-layout.js +5 -1
- package/dist/tui/screen.js +7 -0
- package/dist/tui/session-view.js +0 -1
- package/dist/tui/transcript-grammar.js +1 -8
- package/dist/tui/transcript-view.js +4 -0
- package/dist/tui/turn-workflow.js +47 -76
- package/dist/tui/view.js +4 -4
- package/dist/ui/render.js +0 -4
- package/package.json +5 -1
- package/dist/batch-view.js +0 -42
- package/dist/batch.js +0 -269
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Measure the content each adapter actually sends, without exposing wire
|
|
2
|
+
// formats to context policy or counting the normalized and raw copies twice.
|
|
3
|
+
import { estimateSerializedTokensResponsive } from "../context/estimate.js";
|
|
4
|
+
import { toWireItems, toWireTool as responsesTool } from "./openai-wire.js";
|
|
5
|
+
import { toWireMessage, toWireTool as anthropicTool } from "./anthropic-wire.js";
|
|
6
|
+
import { toWireMessages, toWireTool as ollamaTool } from "./ollama-wire.js";
|
|
7
|
+
import { countO200k } from "../context/tokenizer/o200k.js";
|
|
8
|
+
export function measureResponsesInput(request, providerId, signal) {
|
|
9
|
+
return measure({ instructions: request.system, tools: request.tools.map(responsesTool) }, request.messages.map((message) => {
|
|
10
|
+
const items = toWireItems(message, providerId);
|
|
11
|
+
const outputTokens = opaqueReserve(message, providerId);
|
|
12
|
+
if (outputTokens === undefined)
|
|
13
|
+
return { items,
|
|
14
|
+
unmeasuredOpaque: items.some((item) => hasOpaqueField(item, "reasoning", "encrypted_content")) };
|
|
15
|
+
return {
|
|
16
|
+
items: items.map((item) => withoutOpaqueField(item, "reasoning", "encrypted_content")),
|
|
17
|
+
outputTokens,
|
|
18
|
+
};
|
|
19
|
+
}), request.tools.length, signal, responsesTokenCounter(request.model, providerId));
|
|
20
|
+
}
|
|
21
|
+
export function measureAnthropicInput(request, signal) {
|
|
22
|
+
return measure({ system: request.system, tools: request.tools.map(anthropicTool) }, request.messages.map((message) => {
|
|
23
|
+
const wire = toWireMessage(message);
|
|
24
|
+
const outputTokens = opaqueReserve(message, "anthropic");
|
|
25
|
+
if (outputTokens === undefined || !Array.isArray(wire.content))
|
|
26
|
+
return { items: [wire] };
|
|
27
|
+
return {
|
|
28
|
+
items: [{
|
|
29
|
+
...wire,
|
|
30
|
+
content: wire.content.map((item) => withoutOpaqueField(withoutOpaqueField(item, "thinking", "signature"), "redacted_thinking", "data")),
|
|
31
|
+
}],
|
|
32
|
+
outputTokens,
|
|
33
|
+
};
|
|
34
|
+
}), request.tools.length, signal);
|
|
35
|
+
}
|
|
36
|
+
export function measureOllamaInput(request, signal) {
|
|
37
|
+
return measure({ messages: toWireMessages(request.system, []), tools: request.tools.map(ollamaTool) }, request.messages.map((message) => ({ items: toWireMessages("", [message]) })), request.tools.length, signal);
|
|
38
|
+
}
|
|
39
|
+
async function measure(envelope, messages, toolCount, signal, count = estimateSerializedTokensResponsive) {
|
|
40
|
+
let tokens = 64 + toolCount * 16 + await count(envelope, signal);
|
|
41
|
+
for (const message of messages) {
|
|
42
|
+
if (message.items.length === 0)
|
|
43
|
+
continue;
|
|
44
|
+
const visible = await (message.unmeasuredOpaque ? estimateSerializedTokensResponsive : count)(message.items, signal);
|
|
45
|
+
// Reported output already includes hidden reasoning. Reserve it once for
|
|
46
|
+
// this assistant message, never once per opaque item or in addition to it.
|
|
47
|
+
tokens += Math.max(visible, message.outputTokens ?? 0) + message.items.length * 8;
|
|
48
|
+
}
|
|
49
|
+
return tokens;
|
|
50
|
+
}
|
|
51
|
+
/** Account aliases may precede tiktoken's model map: use a reference encoding,
|
|
52
|
+
* never label this a provider-exact count. Legacy/unknown API routes stay conservative. */
|
|
53
|
+
function responsesTokenCounter(model, providerId) {
|
|
54
|
+
if (responsesTokenization(model, providerId) === "heuristic")
|
|
55
|
+
return estimateSerializedTokensResponsive;
|
|
56
|
+
return async (value, signal) => Math.max(1, Math.ceil(await countO200k(JSON.stringify(value), signal) * 1.1));
|
|
57
|
+
}
|
|
58
|
+
export function responsesTokenization(model, providerId) {
|
|
59
|
+
const modern = /^(?:gpt-5|gpt-4\.[15](?:-|$)|gpt-4o(?:-|$)|chatgpt-4o-|o[13](?:-|$)|o4-mini(?:-|$)|ft:gpt-4o)/u.test(model);
|
|
60
|
+
return providerId === "openai-codex" || modern ? "o200k-reference" : "heuristic";
|
|
61
|
+
}
|
|
62
|
+
function opaqueReserve(message, providerId) {
|
|
63
|
+
const tokens = message.usage?.outputTokens;
|
|
64
|
+
return message.role === "assistant" && message.rawFrom === providerId &&
|
|
65
|
+
Array.isArray(message.raw) && typeof tokens === "number" &&
|
|
66
|
+
Number.isSafeInteger(tokens) && tokens > 0 && message.raw.some((item) => (providerId === "anthropic"
|
|
67
|
+
? hasOpaqueField(item, "thinking", "signature") ||
|
|
68
|
+
hasOpaqueField(item, "redacted_thinking", "data")
|
|
69
|
+
: hasOpaqueField(item, "reasoning", "encrypted_content")))
|
|
70
|
+
? tokens
|
|
71
|
+
: undefined;
|
|
72
|
+
}
|
|
73
|
+
function withoutOpaqueField(value, type, field) {
|
|
74
|
+
if (!hasOpaqueField(value, type, field))
|
|
75
|
+
return value;
|
|
76
|
+
const visible = { ...value };
|
|
77
|
+
delete visible[field];
|
|
78
|
+
return visible;
|
|
79
|
+
}
|
|
80
|
+
function hasOpaqueField(value, type, field) {
|
|
81
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
82
|
+
return false;
|
|
83
|
+
const item = value;
|
|
84
|
+
return item["type"] === type && typeof item[field] === "string";
|
|
85
|
+
}
|
|
@@ -107,6 +107,6 @@ function normalizeUsage(reply) {
|
|
|
107
107
|
}
|
|
108
108
|
export function stopNotice(reply) {
|
|
109
109
|
return reply.finishReason === "length"
|
|
110
|
-
? "[truncated: hit the output limit — raise
|
|
110
|
+
? "[truncated: hit the output limit — raise max output tokens in /settings]"
|
|
111
111
|
: undefined;
|
|
112
112
|
}
|
package/dist/providers/ollama.js
CHANGED
|
@@ -8,6 +8,7 @@ import { ollamaContextWindow } from "./ollama-context.js";
|
|
|
8
8
|
import { isRetryableGenerationFailure, isRetryableReadFailure, throwProviderError, } from "./failure.js";
|
|
9
9
|
import { OLLAMA_CLOUD_HOST } from "./ollama-endpoint.js";
|
|
10
10
|
import { fromWireReply, stopNotice, toWireMessages, toWireTool } from "./ollama-wire.js";
|
|
11
|
+
import { measureOllamaInput } from "./input-measurement.js";
|
|
11
12
|
const KEY = "OLLAMA_API_KEY";
|
|
12
13
|
const ID = "ollama";
|
|
13
14
|
// Ollama also accepts `none`; Jecode's product-wide reasoning floor is `low`.
|
|
@@ -33,6 +34,7 @@ export const ollama = {
|
|
|
33
34
|
async contextWindow(model, signal, onStatus) {
|
|
34
35
|
return ollamaContextWindow(model, headers(), signal, onStatus);
|
|
35
36
|
},
|
|
37
|
+
measureInput: measureOllamaInput,
|
|
36
38
|
async send(req) {
|
|
37
39
|
const effort = requireSupportedEffort(req.model, req.effort, OLLAMA_EFFORTS);
|
|
38
40
|
try {
|
|
@@ -9,6 +9,7 @@ import { isRetryableGenerationFailure, isRetryableReadFailure, throwProviderErro
|
|
|
9
9
|
import { getJson, postSse } from "./http.js";
|
|
10
10
|
import { assembleOpenAI, openAIStreamProgress } from "./openai-stream.js";
|
|
11
11
|
import { fromWireResponse, stopNotice, toWireItems, toWireTool, } from "./openai-wire.js";
|
|
12
|
+
import { measureResponsesInput, responsesTokenization } from "./input-measurement.js";
|
|
12
13
|
const ID = "openai-codex";
|
|
13
14
|
const BASE = "https://chatgpt.com/backend-api/codex";
|
|
14
15
|
// Jecode's product version is unrelated to the Codex protocol gate. OpenAI's
|
|
@@ -66,6 +67,8 @@ export const openaiCodex = {
|
|
|
66
67
|
throwProviderError(ID, signal, error);
|
|
67
68
|
}
|
|
68
69
|
},
|
|
70
|
+
measureInput: (request, signal) => measureResponsesInput(request, ID, signal),
|
|
71
|
+
inputTokenization: (model) => responsesTokenization(model, ID),
|
|
69
72
|
async send(req) {
|
|
70
73
|
const efforts = effortByModel.get(req.model) ?? fallbackEfforts(req.model);
|
|
71
74
|
const effort = requireSupportedEffort(req.model, req.effort, efforts);
|
|
@@ -5,9 +5,16 @@
|
|
|
5
5
|
// an empty final `output` after complete `response.output_item.done` events, so
|
|
6
6
|
// those streamed items remain the fallback when the final envelope is empty.
|
|
7
7
|
import { providerWireError } from "./failure.js";
|
|
8
|
+
import { OpenAISummary } from "./openai-summary.js";
|
|
8
9
|
export async function assembleOpenAI(events, onStream, onStatus) {
|
|
9
10
|
const items = [];
|
|
10
11
|
const announcedTools = { identities: new Set(), anonymous: false };
|
|
12
|
+
const summary = new OpenAISummary();
|
|
13
|
+
const display = (event) => {
|
|
14
|
+
if (event.kind !== "thinking")
|
|
15
|
+
summary.reset();
|
|
16
|
+
onStream?.(event);
|
|
17
|
+
};
|
|
11
18
|
let refusal = false;
|
|
12
19
|
let activity;
|
|
13
20
|
const status = (next) => {
|
|
@@ -27,32 +34,36 @@ export async function assembleOpenAI(events, onStream, onStatus) {
|
|
|
27
34
|
case "response.output_text.delta":
|
|
28
35
|
if (typeof event.delta === "string") {
|
|
29
36
|
status("Responding");
|
|
30
|
-
|
|
37
|
+
display({ kind: "text", text: event.delta });
|
|
31
38
|
}
|
|
32
39
|
break;
|
|
33
40
|
case "response.refusal.delta":
|
|
34
41
|
if (typeof event.delta === "string") {
|
|
35
42
|
status("Responding");
|
|
36
|
-
|
|
43
|
+
display({ kind: "text", text: `${refusal ? "" : "[refused] "}${event.delta}` });
|
|
37
44
|
refusal = true;
|
|
38
45
|
}
|
|
39
46
|
break;
|
|
40
|
-
case "response.reasoning_summary_text.delta":
|
|
41
|
-
|
|
47
|
+
case "response.reasoning_summary_text.delta": {
|
|
48
|
+
const text = summary.delta(event);
|
|
49
|
+
if (text !== undefined) {
|
|
42
50
|
status("Thinking");
|
|
43
|
-
|
|
51
|
+
display({ kind: "thinking", text });
|
|
44
52
|
}
|
|
45
53
|
break;
|
|
54
|
+
}
|
|
46
55
|
case "response.reasoning_summary_part.added":
|
|
56
|
+
summary.end();
|
|
47
57
|
status("Thinking");
|
|
48
58
|
break;
|
|
49
59
|
case "response.reasoning_summary_text.done":
|
|
50
60
|
case "response.reasoning_summary_part.done":
|
|
61
|
+
summary.end();
|
|
51
62
|
status("Working");
|
|
52
63
|
break;
|
|
53
64
|
case "response.output_item.added":
|
|
54
65
|
if (isFunctionCall(event.item)) {
|
|
55
|
-
announceTool(event, event.item, announcedTools,
|
|
66
|
+
announceTool(event, event.item, announcedTools, display, status);
|
|
56
67
|
}
|
|
57
68
|
else if (itemType(event.item) === "reasoning") {
|
|
58
69
|
status("Thinking");
|
|
@@ -63,14 +74,15 @@ export async function assembleOpenAI(events, onStream, onStatus) {
|
|
|
63
74
|
break;
|
|
64
75
|
case "response.function_call_arguments.delta":
|
|
65
76
|
case "response.function_call_arguments.done":
|
|
66
|
-
announceTool(event, undefined, announcedTools,
|
|
77
|
+
announceTool(event, undefined, announcedTools, display, status);
|
|
67
78
|
break;
|
|
68
79
|
case "response.output_item.done":
|
|
69
80
|
if (event.item !== undefined) {
|
|
70
81
|
if (isFunctionCall(event.item)) {
|
|
71
|
-
announceTool(event, event.item, announcedTools,
|
|
82
|
+
announceTool(event, event.item, announcedTools, display, status);
|
|
72
83
|
}
|
|
73
84
|
else if (itemType(event.item) === "reasoning") {
|
|
85
|
+
summary.end();
|
|
74
86
|
status("Working");
|
|
75
87
|
}
|
|
76
88
|
items.push(event.item);
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Display boundaries between Responses summary parts; provider replay stays untouched.
|
|
2
|
+
export class OpenAISummary {
|
|
3
|
+
#part;
|
|
4
|
+
#ended = false;
|
|
5
|
+
#tail = "";
|
|
6
|
+
reset() {
|
|
7
|
+
this.#part = undefined;
|
|
8
|
+
this.#ended = false;
|
|
9
|
+
this.#tail = "";
|
|
10
|
+
}
|
|
11
|
+
end() { this.#ended = true; }
|
|
12
|
+
delta(event) {
|
|
13
|
+
if (typeof event.delta !== "string" || event.delta === "")
|
|
14
|
+
return undefined;
|
|
15
|
+
const part = {
|
|
16
|
+
item: typeof event.item_id === "string" ? event.item_id : undefined,
|
|
17
|
+
output: index(event.output_index),
|
|
18
|
+
summary: index(event.summary_index),
|
|
19
|
+
};
|
|
20
|
+
const previous = this.#part;
|
|
21
|
+
const changed = previous !== undefined && ["item", "output", "summary"].some(key => (part[key] !== undefined && previous[key] !== undefined && part[key] !== previous[key]));
|
|
22
|
+
// Completion events cover older/idless streams. Identity changes also cover
|
|
23
|
+
// streams that omit those events. Wait for text so empty parts add no rows.
|
|
24
|
+
const boundary = previous !== undefined && (this.#ended || changed);
|
|
25
|
+
const trailing = this.#tail.match(/\n{0,2}$/u)?.[0].length ?? 0;
|
|
26
|
+
const leading = event.delta.match(/^\n{0,2}/u)?.[0].length ?? 0;
|
|
27
|
+
const prefix = boundary ? "\n".repeat(Math.max(0, 2 - trailing - leading)) : "";
|
|
28
|
+
const text = prefix + event.delta;
|
|
29
|
+
this.#tail = (this.#tail + text).slice(-2);
|
|
30
|
+
this.#part = part;
|
|
31
|
+
this.#ended = false;
|
|
32
|
+
return text;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function index(value) {
|
|
36
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : undefined;
|
|
37
|
+
}
|
|
@@ -45,7 +45,7 @@ export function stopNotice(data) {
|
|
|
45
45
|
return data.status === "incomplete" ? "[incomplete response]" : undefined;
|
|
46
46
|
}
|
|
47
47
|
return reason === "max_output_tokens"
|
|
48
|
-
? "[truncated: hit max_output_tokens — raise
|
|
48
|
+
? "[truncated: hit max_output_tokens — raise max output tokens in /settings]"
|
|
49
49
|
: `[incomplete: ${reason}]`;
|
|
50
50
|
}
|
|
51
51
|
export function fromWireResponse(data, providerId = "openai") {
|
package/dist/providers/openai.js
CHANGED
|
@@ -11,6 +11,7 @@ import { EFFORTS, requireSupportedEffort } from "../effort.js";
|
|
|
11
11
|
import { isRetryableGenerationFailure, isRetryableReadFailure, throwProviderError, } from "./failure.js";
|
|
12
12
|
import { assembleOpenAI, openAIStreamProgress } from "./openai-stream.js";
|
|
13
13
|
import { fromWireResponse, stopNotice, toWireItems, toWireTool, } from "./openai-wire.js";
|
|
14
|
+
import { measureResponsesInput, responsesTokenization } from "./input-measurement.js";
|
|
14
15
|
const ENDPOINT = "https://api.openai.com/v1/responses";
|
|
15
16
|
const MODELS = "https://api.openai.com/v1/models";
|
|
16
17
|
const KEY = "OPENAI_API_KEY";
|
|
@@ -87,6 +88,8 @@ export const openai = {
|
|
|
87
88
|
async contextWindow(model) {
|
|
88
89
|
return openAIContextWindow(model);
|
|
89
90
|
},
|
|
91
|
+
measureInput: (request, signal) => measureResponsesInput(request, ID, signal),
|
|
92
|
+
inputTokenization: (model) => responsesTokenization(model, ID),
|
|
90
93
|
async send(req) {
|
|
91
94
|
const key = requireKey();
|
|
92
95
|
const effort = requireSupportedEffort(req.model, req.effort, openAIEfforts(req.model));
|
package/dist/settings-command.js
CHANGED
|
@@ -69,31 +69,26 @@ function settingsItems(values) {
|
|
|
69
69
|
option: {
|
|
70
70
|
label: "model",
|
|
71
71
|
value: `${providerLabel(values.provider)} · ${values.model || "choose a model"}`,
|
|
72
|
-
description: "Choose the provider and model used for subsequent requests.",
|
|
73
72
|
},
|
|
74
73
|
},
|
|
75
|
-
{ action: "effort", option: { label: "effort", value: values.effort,
|
|
76
|
-
description: "Set the saved reasoning effort for model requests." } },
|
|
74
|
+
{ action: "effort", option: { label: "effort", value: values.effort } },
|
|
77
75
|
...(values.maxTokens === undefined
|
|
78
76
|
? []
|
|
79
77
|
: [{
|
|
80
78
|
action: "maxTokens",
|
|
81
|
-
option: { label: "max output tokens", value: String(values.maxTokens),
|
|
82
|
-
description: "Limit the output tokens requested from the model in each response." },
|
|
79
|
+
option: { label: "max output tokens", value: String(values.maxTokens) },
|
|
83
80
|
}]),
|
|
84
81
|
{
|
|
85
82
|
action: "compactionPercent",
|
|
86
|
-
option: { label: "context compaction", value: `${values.compactionPercent}
|
|
87
|
-
description: "Compact model context near this share of its usable window. The transcript stays complete." },
|
|
83
|
+
option: { label: "context compaction", value: `${values.compactionPercent}%` },
|
|
88
84
|
},
|
|
89
85
|
{
|
|
90
86
|
action: "reducedMotion",
|
|
91
|
-
option: { label: "reduced motion", value: values.reducedMotion ? "on" : "off",
|
|
92
|
-
description: "Keep decorative movement still while output continues to update." },
|
|
87
|
+
option: { label: "reduced motion", value: values.reducedMotion ? "on" : "off" },
|
|
93
88
|
},
|
|
94
89
|
{
|
|
95
90
|
action: "providers",
|
|
96
|
-
option: { label: "providers", hint: "manage connections"
|
|
91
|
+
option: { label: "providers", hint: "manage connections" },
|
|
97
92
|
},
|
|
98
93
|
];
|
|
99
94
|
}
|
package/dist/settings.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Persistent, non-secret defaults for interactive
|
|
1
|
+
// Persistent, non-secret defaults for interactive sessions.
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { atomicWrite } from "./atomic.js";
|
|
4
4
|
import { assertDirectoryAnchor, captureDirectDirectorySync, preparePrivateDirectory, } from "./directory-anchor.js";
|
package/dist/start.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
// Application bootstrap:
|
|
1
|
+
// Application bootstrap: validate the terminal and resolve one interactive session.
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
-
import { runBatch } from "./batch.js";
|
|
4
3
|
import { showCliInfo } from "./cli-info.js";
|
|
5
4
|
import { loadConfig } from "./config.js";
|
|
6
5
|
import { ConversationTree } from "./conversation.js";
|
|
@@ -22,22 +21,17 @@ export async function start(args = process.argv.slice(2), environment = {}) {
|
|
|
22
21
|
if (await showCliInfo(args, applicationRoot, write))
|
|
23
22
|
return;
|
|
24
23
|
const launch = parseLaunch(args);
|
|
25
|
-
const config = loadConfig(launch.configArgs);
|
|
26
|
-
const provider = selectProvider(config.providerId);
|
|
27
24
|
const hasScreen = environment.interactive?.() ?? interactive();
|
|
28
|
-
if (
|
|
29
|
-
throw new Error("
|
|
25
|
+
if (!hasScreen) {
|
|
26
|
+
throw new Error("an interactive terminal is required on stdin and stdout; run jecode directly without pipes or redirection");
|
|
30
27
|
}
|
|
28
|
+
const config = loadConfig(launch.configArgs, environment.readSettings?.());
|
|
29
|
+
const provider = selectProvider(config.providerId);
|
|
31
30
|
if (launch.kind === "resume" && config.ephemeral) {
|
|
32
31
|
throw new Error("--ephemeral cannot be combined with resume");
|
|
33
32
|
}
|
|
34
|
-
// A provider whose catalogue is not fixed has no sensible default model.
|
|
35
|
-
// The TUI can ask; a pipe cannot, so batch mode still requires one up front.
|
|
36
33
|
const model = config.model === "" ? provider.defaultModel : config.model;
|
|
37
|
-
|
|
38
|
-
throw new Error(`${provider.id} has no default model — pass --model <id> (or set JECODE_MODEL)`);
|
|
39
|
-
}
|
|
40
|
-
configureColor(hasScreen);
|
|
34
|
+
configureColor(true);
|
|
41
35
|
const session = {
|
|
42
36
|
config,
|
|
43
37
|
provider,
|
|
@@ -48,52 +42,42 @@ export async function start(args = process.argv.slice(2), environment = {}) {
|
|
|
48
42
|
conversation: ConversationTree.empty(),
|
|
49
43
|
usage: emptyUsage(),
|
|
50
44
|
};
|
|
51
|
-
if (
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
session.resume = { candidates, open };
|
|
72
|
-
}
|
|
73
|
-
else {
|
|
74
|
-
session.persistence = SessionPersistence.fresh(store);
|
|
75
|
-
}
|
|
45
|
+
if (!config.ephemeral) {
|
|
46
|
+
const store = await DurableSessionStore.open(config.root, environment.sessionsRoot);
|
|
47
|
+
if (launch.kind === "resume") {
|
|
48
|
+
const candidates = await SessionPersistence.candidates(store);
|
|
49
|
+
if (candidates.length === 0)
|
|
50
|
+
throw new Error("no resumable sessions found for this workspace");
|
|
51
|
+
const open = async (id) => {
|
|
52
|
+
const resumed = await SessionPersistence.resume(store, id);
|
|
53
|
+
try {
|
|
54
|
+
applyResumedSession(session, resumed.conversation, resumed.persistence);
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
await resumed.persistence.close();
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
if (launch.latest)
|
|
62
|
+
await open(candidates[0].id);
|
|
63
|
+
else
|
|
64
|
+
session.resume = { candidates, open };
|
|
76
65
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
await runApp(session, transcriptRoot, { shutdownSignal: environment.signal });
|
|
80
|
-
}
|
|
81
|
-
else {
|
|
82
|
-
await environment.runInteractive(session, transcriptRoot, environment.signal);
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
finally {
|
|
86
|
-
await session.persistence?.close();
|
|
66
|
+
else {
|
|
67
|
+
session.persistence = SessionPersistence.fresh(store);
|
|
87
68
|
}
|
|
88
69
|
}
|
|
89
|
-
|
|
90
|
-
if (environment.
|
|
91
|
-
await
|
|
70
|
+
try {
|
|
71
|
+
if (environment.runInteractive === undefined) {
|
|
72
|
+
await runApp(session, transcriptRoot, { shutdownSignal: environment.signal });
|
|
92
73
|
}
|
|
93
74
|
else {
|
|
94
|
-
await environment.
|
|
75
|
+
await environment.runInteractive(session, transcriptRoot, environment.signal);
|
|
95
76
|
}
|
|
96
77
|
}
|
|
78
|
+
finally {
|
|
79
|
+
await session.persistence?.close();
|
|
80
|
+
}
|
|
97
81
|
}
|
|
98
82
|
function applyResumedSession(session, conversation, persistence) {
|
|
99
83
|
const identity = conversation.activeNode?.identity;
|
package/dist/timeline.js
CHANGED
|
@@ -16,6 +16,8 @@ export function timelinePicker(conversation, palette) {
|
|
|
16
16
|
searchable: true,
|
|
17
17
|
query: "",
|
|
18
18
|
visible: 8,
|
|
19
|
+
// Turn labels are previews; keep each node on its own row without a second copy.
|
|
20
|
+
overflow: "truncate",
|
|
19
21
|
options: entries.map((entry) => ({
|
|
20
22
|
label: `${entry.prefix}${preview(entry.node)}`,
|
|
21
23
|
hint: stamp(entry.node.createdAt),
|
package/dist/tui/activity.js
CHANGED
package/dist/tui/app-input.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// Keyboard and pointer intent for the TUI shell.
|
|
2
|
+
import { commandNeedsIdle } from "../commands.js";
|
|
2
3
|
import { PROMPT_LIMIT_MESSAGE, PromptLimitError } from "../input-boundary.js";
|
|
3
4
|
import { activate as activateCompletion, move as moveCompletion, selected as selectedCompletion, } from "./complete.js";
|
|
4
5
|
import * as edit from "./editor.js";
|
|
@@ -11,7 +12,7 @@ const WHEEL_STEP = 3;
|
|
|
11
12
|
export function appInput(options) {
|
|
12
13
|
const { session, state, feedback, actions } = options;
|
|
13
14
|
function handle(key) {
|
|
14
|
-
if (!options.live())
|
|
15
|
+
if (!options.live() || state.closeWhenIdle)
|
|
15
16
|
return;
|
|
16
17
|
// The wheel only changes what is visible. It never answers an open prompt.
|
|
17
18
|
if (key.name === "pointer") {
|
|
@@ -23,7 +24,7 @@ export function appInput(options) {
|
|
|
23
24
|
if (state.feedback !== undefined)
|
|
24
25
|
feedback.dismiss();
|
|
25
26
|
if (key.name === "input_limit") {
|
|
26
|
-
if (state.open === undefined)
|
|
27
|
+
if (state.open === undefined && state.approval === undefined)
|
|
27
28
|
rejectPrompt();
|
|
28
29
|
else
|
|
29
30
|
showInputLimit();
|
|
@@ -38,20 +39,27 @@ export function appInput(options) {
|
|
|
38
39
|
options.transcriptChanged(changed);
|
|
39
40
|
return;
|
|
40
41
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
state.
|
|
42
|
+
const open = state.approval ?? state.open;
|
|
43
|
+
if (open !== undefined) {
|
|
44
|
+
const approving = state.approval !== undefined;
|
|
45
|
+
const outcome = overlay.handle(open, key);
|
|
46
|
+
if (approving)
|
|
47
|
+
state.approval = outcome.open;
|
|
48
|
+
else
|
|
49
|
+
state.open = outcome.open;
|
|
44
50
|
if (outcome.inputLimit === true)
|
|
45
51
|
showInputLimit();
|
|
46
|
-
if (outcome.abort === true)
|
|
47
|
-
state.activity?.control.abort(new Error("interrupted"));
|
|
52
|
+
if (outcome.abort === true) {
|
|
53
|
+
(approving ? state.activity : state.command ?? state.activity)?.control.abort(new Error("interrupted"));
|
|
54
|
+
}
|
|
48
55
|
if (outcome.quit === true)
|
|
49
56
|
options.requestQuit();
|
|
50
57
|
return;
|
|
51
58
|
}
|
|
52
59
|
if (key.ctrl && key.name === "c") {
|
|
53
|
-
|
|
54
|
-
|
|
60
|
+
const activity = state.command ?? state.activity;
|
|
61
|
+
if (activity !== undefined)
|
|
62
|
+
activity.control.abort(new Error("interrupted"));
|
|
55
63
|
else
|
|
56
64
|
options.quit();
|
|
57
65
|
return;
|
|
@@ -66,7 +74,7 @@ export function appInput(options) {
|
|
|
66
74
|
state.completing = undefined;
|
|
67
75
|
return;
|
|
68
76
|
}
|
|
69
|
-
state.activity?.control.abort(new Error("interrupted"));
|
|
77
|
+
(state.command ?? state.activity)?.control.abort(new Error("interrupted"));
|
|
70
78
|
return;
|
|
71
79
|
case "enter": {
|
|
72
80
|
if (state.completing !== undefined) {
|
|
@@ -81,7 +89,7 @@ export function appInput(options) {
|
|
|
81
89
|
return;
|
|
82
90
|
}
|
|
83
91
|
case "tab": {
|
|
84
|
-
if (state.
|
|
92
|
+
if (state.command !== undefined)
|
|
85
93
|
return;
|
|
86
94
|
const completion = state.completing ?? activateCompletion(state.editor.text);
|
|
87
95
|
const completed = completion === undefined ? undefined : selectedCompletion(completion);
|
|
@@ -125,7 +133,7 @@ export function appInput(options) {
|
|
|
125
133
|
if (edited.text !== state.editor.text)
|
|
126
134
|
state.promptRejected = false;
|
|
127
135
|
state.editor = edited;
|
|
128
|
-
state.completing = state.
|
|
136
|
+
state.completing = state.command === undefined ? activateCompletion(edited.text) : undefined;
|
|
129
137
|
}
|
|
130
138
|
}
|
|
131
139
|
catch (error) {
|
|
@@ -163,11 +171,20 @@ export function appInput(options) {
|
|
|
163
171
|
if (text === "")
|
|
164
172
|
return;
|
|
165
173
|
const isCommand = text.startsWith("/");
|
|
166
|
-
if (
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
174
|
+
if (isCommand && text.slice(1).trim().split(/\s+/)[0] === "exit") {
|
|
175
|
+
accept(text);
|
|
176
|
+
options.requestQuit();
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (state.command !== undefined) {
|
|
180
|
+
keep("Wait for the active command to finish");
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (isCommand && state.activity !== undefined && commandNeedsIdle(text)) {
|
|
184
|
+
keep(`Stop the active turn before ${text.split(/\s+/)[0]}`);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (state.activity !== undefined && !isCommand) {
|
|
171
188
|
const result = actions.steer(text);
|
|
172
189
|
if (result !== "queued") {
|
|
173
190
|
keep(result === "full"
|
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
// Wire foreground workflows to the shell and their shared compaction lifetime.
|
|
2
2
|
import { automaticCompactionGate } from "../context/automatic.js";
|
|
3
|
+
import { inputLifetime } from "../context/lifetime.js";
|
|
3
4
|
import { commandWorkflow } from "./command-workflow.js";
|
|
4
5
|
import { turnWorkflow } from "./turn-workflow.js";
|
|
5
6
|
export function appWorkflows(options) {
|
|
6
7
|
const automaticCompaction = automaticCompactionGate();
|
|
8
|
+
const inputs = inputLifetime();
|
|
7
9
|
return {
|
|
8
|
-
command: commandWorkflow(options, () =>
|
|
9
|
-
|
|
10
|
+
command: commandWorkflow(options, () => {
|
|
11
|
+
automaticCompaction.reset();
|
|
12
|
+
inputs.reset();
|
|
13
|
+
}, () => inputs.reset()),
|
|
14
|
+
...turnWorkflow(options, automaticCompaction, inputs),
|
|
10
15
|
};
|
|
11
16
|
}
|