@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.
Files changed (67) hide show
  1. package/README.md +18 -8
  2. package/assets/tokenizers/LICENSE +21 -0
  3. package/assets/tokenizers/o200k-base.tiktoken.gz +0 -0
  4. package/dist/cli-info.js +8 -13
  5. package/dist/commands.js +7 -3
  6. package/dist/config.js +33 -47
  7. package/dist/context/automatic.js +16 -1
  8. package/dist/context/budget.js +5 -2
  9. package/dist/context/compactor.js +91 -37
  10. package/dist/context/diagnostics.js +108 -0
  11. package/dist/context/lifetime.js +18 -0
  12. package/dist/context/manager.js +67 -0
  13. package/dist/context/manual.js +11 -10
  14. package/dist/context/measurement.js +75 -0
  15. package/dist/context/policy.js +13 -10
  16. package/dist/context/request-observation.js +46 -0
  17. package/dist/context/request-projection.js +8 -34
  18. package/dist/context/request.js +22 -0
  19. package/dist/context/tokenizer/bpe.js +68 -0
  20. package/dist/context/tokenizer/o200k.js +54 -0
  21. package/dist/context/tokenizer/vocabulary.js +34 -0
  22. package/dist/controller-request.js +47 -45
  23. package/dist/controller.js +13 -2
  24. package/dist/input-boundary.js +0 -62
  25. package/dist/launch.js +13 -9
  26. package/dist/model-command.js +7 -17
  27. package/dist/oauth-result-page.js +68 -0
  28. package/dist/openai-oauth-callback.js +2 -50
  29. package/dist/permission-command.js +9 -24
  30. package/dist/permissions.js +10 -8
  31. package/dist/providers/anthropic-wire.js +1 -1
  32. package/dist/providers/anthropic.js +3 -1
  33. package/dist/providers/input-measurement.js +85 -0
  34. package/dist/providers/ollama-wire.js +1 -1
  35. package/dist/providers/ollama.js +2 -0
  36. package/dist/providers/openai-codex.js +3 -0
  37. package/dist/providers/openai-stream.js +20 -8
  38. package/dist/providers/openai-summary.js +37 -0
  39. package/dist/providers/openai-wire.js +1 -1
  40. package/dist/providers/openai.js +3 -0
  41. package/dist/settings-command.js +5 -10
  42. package/dist/settings.js +1 -1
  43. package/dist/start.js +35 -51
  44. package/dist/timeline.js +2 -0
  45. package/dist/tui/activity.js +1 -1
  46. package/dist/tui/app-input.js +34 -17
  47. package/dist/tui/app-workflows.js +7 -2
  48. package/dist/tui/app.js +55 -28
  49. package/dist/tui/approve.js +3 -4
  50. package/dist/tui/blocks.js +5 -7
  51. package/dist/tui/command-workflow.js +14 -8
  52. package/dist/tui/components/command-menu.js +1 -1
  53. package/dist/tui/components/menu.js +19 -11
  54. package/dist/tui/components/status.js +1 -1
  55. package/dist/tui/frame.js +8 -3
  56. package/dist/tui/help.js +2 -1
  57. package/dist/tui/picker-layout.js +5 -1
  58. package/dist/tui/screen.js +7 -0
  59. package/dist/tui/session-view.js +0 -1
  60. package/dist/tui/transcript-grammar.js +1 -8
  61. package/dist/tui/transcript-view.js +4 -0
  62. package/dist/tui/turn-workflow.js +47 -76
  63. package/dist/tui/view.js +4 -4
  64. package/dist/ui/render.js +0 -4
  65. package/package.json +5 -1
  66. package/dist/batch-view.js +0 -42
  67. 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 --max-tokens]"
110
+ ? "[truncated: hit the output limit — raise max output tokens in /settings]"
111
111
  : undefined;
112
112
  }
@@ -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
- onStream?.({ kind: "text", text: event.delta });
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
- onStream?.({ kind: "text", text: `${refusal ? "" : "[refused] "}${event.delta}` });
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
- if (typeof event.delta === "string") {
47
+ case "response.reasoning_summary_text.delta": {
48
+ const text = summary.delta(event);
49
+ if (text !== undefined) {
42
50
  status("Thinking");
43
- onStream?.({ kind: "thinking", text: event.delta });
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, onStream, status);
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, onStream, status);
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, onStream, status);
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 --max-tokens]"
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") {
@@ -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));
@@ -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", description: "Manage API keys and account 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 and batch sessions.
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: resolve one session, then choose its terminal surface.
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 (launch.kind === "resume" && !hasScreen) {
29
- throw new Error("resume needs an interactive terminal");
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
- if (model === "" && !hasScreen) {
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 (hasScreen) {
52
- if (!config.ephemeral) {
53
- const store = await DurableSessionStore.open(config.root, environment.sessionsRoot);
54
- if (launch.kind === "resume") {
55
- const candidates = await SessionPersistence.candidates(store);
56
- if (candidates.length === 0)
57
- throw new Error("no resumable sessions found for this workspace");
58
- const open = async (id) => {
59
- const resumed = await SessionPersistence.resume(store, id);
60
- try {
61
- applyResumedSession(session, resumed.conversation, resumed.persistence);
62
- }
63
- catch (error) {
64
- await resumed.persistence.close();
65
- throw error;
66
- }
67
- };
68
- if (launch.latest)
69
- await open(candidates[0].id);
70
- else
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
- try {
78
- if (environment.runInteractive === undefined) {
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
- else {
90
- if (environment.runNonInteractive === undefined) {
91
- await runBatch(session, { signal: environment.signal });
70
+ try {
71
+ if (environment.runInteractive === undefined) {
72
+ await runApp(session, transcriptRoot, { shutdownSignal: environment.signal });
92
73
  }
93
74
  else {
94
- await environment.runNonInteractive(session, environment.signal);
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),
@@ -1,4 +1,4 @@
1
- // One foreground operation owns cancellation and elapsed time.
1
+ // Each turn or command owns its cancellation and elapsed time.
2
2
  export function begin(kind, label, now = Date.now()) {
3
3
  return {
4
4
  kind,
@@ -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
- if (state.open !== undefined) {
42
- const outcome = overlay.handle(state.open, key);
43
- state.open = outcome.open;
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
- if (state.activity !== undefined)
54
- state.activity.control.abort(new Error("interrupted"));
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.activity !== undefined)
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.activity === undefined ? activateCompletion(edited.text) : undefined;
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 (state.activity !== undefined) {
167
- if (isCommand) {
168
- keep("Slash commands run after the active work finishes");
169
- return;
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, () => automaticCompaction.reset()),
9
- ...turnWorkflow(options, automaticCompaction),
10
+ command: commandWorkflow(options, () => {
11
+ automaticCompaction.reset();
12
+ inputs.reset();
13
+ }, () => inputs.reset()),
14
+ ...turnWorkflow(options, automaticCompaction, inputs),
10
15
  };
11
16
  }