@giovannijecha/jecode 0.8.1 → 0.8.3

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 (48) hide show
  1. package/README.md +19 -278
  2. package/dist/accounts.js +17 -13
  3. package/dist/batch.js +54 -6
  4. package/dist/context/budget.js +28 -4
  5. package/dist/context/compactor.js +5 -4
  6. package/dist/context/estimate.js +43 -1
  7. package/dist/context/manual.js +8 -4
  8. package/dist/context/policy.js +68 -18
  9. package/dist/controller-request.js +23 -15
  10. package/dist/controller.js +26 -5
  11. package/dist/conversation.js +94 -33
  12. package/dist/credential-safety.js +56 -9
  13. package/dist/credentials.js +32 -4
  14. package/dist/input-boundary.js +80 -0
  15. package/dist/main.js +4 -1
  16. package/dist/openai-oauth-callback.js +1 -1
  17. package/dist/openai-oauth.js +59 -15
  18. package/dist/process-shutdown.js +52 -0
  19. package/dist/providers/anthropic-stream.js +24 -20
  20. package/dist/providers/anthropic-wire.js +7 -2
  21. package/dist/providers/ollama-wire.js +7 -15
  22. package/dist/providers/ollama.js +27 -10
  23. package/dist/providers/openai-wire.js +2 -16
  24. package/dist/providers/tool-input.js +17 -0
  25. package/dist/sessions/codec.js +2 -1
  26. package/dist/sessions/lease.js +7 -0
  27. package/dist/sessions/runtime.js +11 -3
  28. package/dist/sessions/store.js +90 -31
  29. package/dist/settings.js +10 -5
  30. package/dist/start.js +12 -2
  31. package/dist/text-boundary.js +2 -0
  32. package/dist/tui/app-input.js +40 -5
  33. package/dist/tui/app-state.js +1 -0
  34. package/dist/tui/app-workflows.js +1 -0
  35. package/dist/tui/app.js +11 -3
  36. package/dist/tui/blocks.js +1 -3
  37. package/dist/tui/components/messages.js +9 -13
  38. package/dist/tui/components/tool.js +2 -4
  39. package/dist/tui/editor.js +2 -0
  40. package/dist/tui/keys.js +64 -5
  41. package/dist/tui/overlay.js +12 -4
  42. package/dist/tui/picker.js +2 -0
  43. package/dist/tui/screen.js +5 -17
  44. package/dist/tui/transcript-grammar.js +1 -1
  45. package/dist/ui/theme.js +18 -18
  46. package/dist/user-store.js +54 -0
  47. package/package.json +6 -3
  48. /package/{docs/assets/brand → assets}/jeco-256.png +0 -0
@@ -1,6 +1,7 @@
1
1
  // Translation between the normalized vocabulary and the OpenAI Responses wire
2
2
  // shape: a flat `input` list where tool calls and their outputs are top-level
3
3
  // items keyed by `call_id`, rather than blocks nested inside a message.
4
+ import { toolInputFromJson } from "./tool-input.js";
4
5
  import { wireTokenCount } from "./wire-usage.js";
5
6
  export function toWireTool(tool) {
6
7
  return {
@@ -73,7 +74,7 @@ export function fromWireResponse(data, providerId = "openai") {
73
74
  kind: "tool_call",
74
75
  id: item.call_id,
75
76
  name: item.name,
76
- input: parseArguments(item.arguments),
77
+ ...toolInputFromJson(item.arguments),
77
78
  });
78
79
  }
79
80
  else {
@@ -106,18 +107,3 @@ function normalizeUsage(data) {
106
107
  reasoningTokens: wireTokenCount(usage.output_tokens_details?.reasoning_tokens),
107
108
  };
108
109
  }
109
- // Arguments arrive as a JSON string and models vary in how they escape it, so
110
- // this always goes through a real parse — never string matching.
111
- function parseArguments(text) {
112
- if (text === undefined || text === "")
113
- return {};
114
- try {
115
- const parsed = JSON.parse(text);
116
- return typeof parsed === "object" && parsed !== null
117
- ? parsed
118
- : {};
119
- }
120
- catch {
121
- return {};
122
- }
123
- }
@@ -0,0 +1,17 @@
1
+ // Provider-neutral validation for tool arguments crossing a wire boundary.
2
+ export function toolInputFromJson(text) {
3
+ if (text === undefined || text.trim() === "")
4
+ return { input: {} };
5
+ try {
6
+ return toolInputFromValue(JSON.parse(text));
7
+ }
8
+ catch {
9
+ return { input: {}, inputError: "tool arguments were not valid JSON" };
10
+ }
11
+ }
12
+ export function toolInputFromValue(value) {
13
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
14
+ return { input: value };
15
+ }
16
+ return { input: {}, inputError: "tool arguments must be a JSON object" };
17
+ }
@@ -3,9 +3,10 @@
3
3
  // can become conversation or provider input.
4
4
  import { Buffer } from "node:buffer";
5
5
  import { CONTEXT_LIMITS } from "../context/projection.js";
6
+ import { MAX_TEXT_CODE_UNITS } from "../text-boundary.js";
6
7
  export const SESSION_SCHEMA = 4;
7
8
  export const SESSION_FILE_LIMITS = Object.freeze({
8
- text: 1_048_576,
9
+ text: MAX_TEXT_CODE_UNITS,
9
10
  metadataBytes: 64 * 1_024,
10
11
  nodeBytes: 20 * 1_024 * 1_024,
11
12
  jsonDepth: 24,
@@ -9,6 +9,13 @@ export function sessionLease(id, file, token) {
9
9
  let closed = false;
10
10
  return Object.freeze({
11
11
  id,
12
+ assertOwned: async () => {
13
+ if (closed)
14
+ throw new Error("session lease is closed");
15
+ const current = await readLease(file);
16
+ if (current !== token)
17
+ throw new Error("session lease is no longer owned by this process");
18
+ },
12
19
  close: async () => {
13
20
  if (closed)
14
21
  return;
@@ -8,11 +8,13 @@ export class SessionPersistence {
8
8
  #store;
9
9
  #sessionId;
10
10
  #lease;
11
+ #snapshot;
11
12
  #failure;
12
- constructor(store, sessionId, lease) {
13
+ constructor(store, sessionId, lease, snapshot) {
13
14
  this.#store = store;
14
15
  this.#sessionId = sessionId;
15
16
  this.#lease = lease;
17
+ this.#snapshot = snapshot;
16
18
  }
17
19
  static fresh(store) {
18
20
  return new SessionPersistence(store, null);
@@ -26,7 +28,7 @@ export class SessionPersistence {
26
28
  throw new Error("session has no resumable turn");
27
29
  return Object.freeze({
28
30
  conversation,
29
- persistence: new SessionPersistence(store, id, lease),
31
+ persistence: new SessionPersistence(store, id, lease, snapshot),
30
32
  });
31
33
  }
32
34
  catch (error) {
@@ -51,9 +53,14 @@ export class SessionPersistence {
51
53
  const published = await this.#store.publish(conversation, true);
52
54
  this.#lease = published.lease;
53
55
  this.#sessionId = published.meta.id;
56
+ this.#snapshot = published;
54
57
  return;
55
58
  }
56
- await this.#store.checkpoint(this.#sessionId, conversation);
59
+ if (this.#lease === undefined || this.#snapshot === undefined) {
60
+ throw new Error("session persistence has no verified owner snapshot");
61
+ }
62
+ await this.#lease.assertOwned();
63
+ this.#snapshot = await this.#store.checkpoint(this.#snapshot, conversation);
57
64
  }
58
65
  catch (error) {
59
66
  this.#failure = error;
@@ -64,6 +71,7 @@ export class SessionPersistence {
64
71
  await this.#lease?.close();
65
72
  this.#lease = undefined;
66
73
  this.#sessionId = null;
74
+ this.#snapshot = undefined;
67
75
  this.#failure = undefined;
68
76
  }
69
77
  async close() {
@@ -16,6 +16,7 @@ const DIRECTORY_MODE = 0o700;
16
16
  const FILE_MODE = 0o600;
17
17
  const MAX_CATALOG_ENTRIES = 4_096;
18
18
  const CATALOG_READ_CONCURRENCY = 8;
19
+ const NODE_READ_CONCURRENCY = 4;
19
20
  const SESSION_NAME = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
20
21
  const NODE_NAME = /^(\d{6})\.json$/;
21
22
  const ATOMIC_NODE_TEMP = /^\.\d{6}\.json\.\d+\.[a-f0-9-]+\.tmp$/;
@@ -104,15 +105,18 @@ export class DurableSessionStore {
104
105
  if (!replacesHead && !extendsTree) {
105
106
  throw new Error("session checkpoint cannot be recovered safely");
106
107
  }
107
- head = {
108
+ head = Object.freeze({
108
109
  version: SESSION_SCHEMA,
109
110
  sequence: candidate.sequence,
110
111
  nodeId: candidate.node.id,
111
112
  parentId: candidate.node.parentId,
112
113
  revision: candidate.node.revision,
113
114
  updatedAt: candidate.updatedAt,
114
- };
115
- await atomicWrite(path.join(directory, "head.json"), encodeHead(head), { mode: FILE_MODE });
115
+ });
116
+ await atomicWrite(path.join(directory, "head.json"), encodeHead(head), {
117
+ mode: FILE_MODE,
118
+ validate: async () => assertDirectory(directory),
119
+ });
116
120
  }
117
121
  const nodes = stored.map((entry) => entry.node);
118
122
  const conversation = ConversationTree.restore(nodes, head.nodeId);
@@ -128,21 +132,21 @@ export class DurableSessionStore {
128
132
  const token = claim === true ? leaseToken() : undefined;
129
133
  const temporary = path.join(this.#bucket, `.${id}.${randomUUID()}.tmp`);
130
134
  const target = this.#sessionDirectory(id);
131
- const meta = {
135
+ const meta = Object.freeze({
132
136
  version: SESSION_SCHEMA,
133
137
  id,
134
138
  workspaceRoot: this.workspaceRoot,
135
139
  workspaceDigest: this.workspaceDigest,
136
140
  createdAt: now,
137
- };
138
- const head = {
141
+ });
142
+ const head = Object.freeze({
139
143
  version: SESSION_SCHEMA,
140
144
  sequence: conversation.nodes.length,
141
145
  nodeId: active.id,
142
146
  parentId: active.parentId,
143
147
  revision: active.revision,
144
148
  updatedAt: now,
145
- };
149
+ });
146
150
  try {
147
151
  await makePrivateDirectory(temporary);
148
152
  const nodes = path.join(temporary, "nodes");
@@ -168,12 +172,26 @@ export class DurableSessionStore {
168
172
  const lease = sessionLease(id, path.join(target, "active"), token);
169
173
  return Object.freeze({ ...snapshot, lease });
170
174
  }
171
- async checkpoint(id, conversation) {
172
- const previous = await this.load(id);
175
+ async checkpoint(previous, conversation) {
176
+ assertSnapshot(previous, this.workspaceRoot, this.workspaceDigest);
177
+ const id = previous.meta.id;
178
+ const directory = this.#sessionDirectory(id);
179
+ await assertDirectory(directory);
180
+ const nodesDirectory = path.join(directory, "nodes");
181
+ const validateNodesDirectory = async () => {
182
+ await assertDirectory(directory);
183
+ await assertDirectory(nodesDirectory);
184
+ };
185
+ await validateNodesDirectory();
186
+ const currentHead = decodeHead(await readJson(path.join(directory, "head.json"), SESSION_FILE_LIMITS.metadataBytes));
187
+ if (!sameHead(currentHead, previous.head)) {
188
+ throw new Error("session head changed after its verified snapshot");
189
+ }
173
190
  const active = conversation.activeNode;
174
191
  if (active === undefined)
175
192
  throw new Error("an empty conversation cannot be checkpointed");
176
- if (sameNode(active, previous.conversation.activeNode)) {
193
+ if (active === previous.conversation.activeNode &&
194
+ conversation.nodes === previous.conversation.nodes) {
177
195
  return Object.freeze({ ...previous, conversation });
178
196
  }
179
197
  const replacesHead = active.id === previous.head.nodeId &&
@@ -186,18 +204,23 @@ export class DurableSessionStore {
186
204
  throw new Error("session checkpoint does not extend its durable tree");
187
205
  }
188
206
  assertSharedNodes(previous.conversation, conversation, replacesHead ? active.id : undefined);
207
+ if (extendsTree) {
208
+ await assertMissingNode(path.join(nodesDirectory, nodeName(active.id)));
209
+ }
189
210
  const now = new Date().toISOString();
190
- const head = {
211
+ const head = Object.freeze({
191
212
  version: SESSION_SCHEMA,
192
213
  sequence: previous.head.sequence + 1,
193
214
  nodeId: active.id,
194
215
  parentId: active.parentId,
195
216
  revision: active.revision,
196
217
  updatedAt: now,
197
- };
198
- const directory = this.#sessionDirectory(id);
199
- await atomicWrite(path.join(directory, "nodes", nodeName(active.id)), encodeNode(active, head.sequence, now), { mode: FILE_MODE });
200
- await atomicWrite(path.join(directory, "head.json"), encodeHead(head), { mode: FILE_MODE });
218
+ });
219
+ await atomicWrite(path.join(nodesDirectory, nodeName(active.id)), encodeNode(active, head.sequence, now), { mode: FILE_MODE, validate: async () => validateNodesDirectory() });
220
+ await atomicWrite(path.join(directory, "head.json"), encodeHead(head), {
221
+ mode: FILE_MODE,
222
+ validate: async () => assertDirectory(directory),
223
+ });
201
224
  return Object.freeze({ meta: previous.meta, head, conversation });
202
225
  }
203
226
  async claim(id) {
@@ -270,17 +293,44 @@ function assertSharedNodes(previous, next, replacedId) {
270
293
  if (node.id === replacedId)
271
294
  continue;
272
295
  const candidate = next.node(node.id);
273
- if (candidate === undefined || normalizedNode(candidate) !== normalizedNode(node)) {
296
+ if (candidate !== node) {
274
297
  throw new Error("session checkpoint rewrites prior conversation history");
275
298
  }
276
299
  }
277
300
  }
278
- function normalizedNode(node) {
279
- const encoded = JSON.parse(encodeNode(node, 1, "2026-01-01T00:00:00.000Z"));
280
- return JSON.stringify(encoded.node);
301
+ function assertSnapshot(snapshot, workspaceRoot, workspaceDigest) {
302
+ encodeMeta(snapshot.meta);
303
+ encodeHead(snapshot.head);
304
+ assertSessionId(snapshot.meta.id);
305
+ if (snapshot.meta.workspaceDigest !== workspaceDigest ||
306
+ workspaceKey(snapshot.meta.workspaceRoot) !== workspaceKey(workspaceRoot))
307
+ throw new Error("session snapshot belongs to a different workspace");
308
+ const active = snapshot.conversation.activeNode;
309
+ if (active === undefined ||
310
+ active.id !== snapshot.head.nodeId ||
311
+ active.parentId !== snapshot.head.parentId ||
312
+ active.revision !== snapshot.head.revision ||
313
+ snapshot.head.sequence < snapshot.conversation.nodes.length)
314
+ throw new Error("session snapshot does not match its verified head");
281
315
  }
282
- function sameNode(left, right) {
283
- return right !== undefined && normalizedNode(left) === normalizedNode(right);
316
+ function sameHead(left, right) {
317
+ return left.version === right.version &&
318
+ left.sequence === right.sequence &&
319
+ left.nodeId === right.nodeId &&
320
+ left.parentId === right.parentId &&
321
+ left.revision === right.revision &&
322
+ left.updatedAt === right.updatedAt;
323
+ }
324
+ async function assertMissingNode(file) {
325
+ try {
326
+ await lstat(file);
327
+ }
328
+ catch (error) {
329
+ if (error.code === "ENOENT")
330
+ return;
331
+ throw error;
332
+ }
333
+ throw new Error("session has an incomplete node outside its verified snapshot");
284
334
  }
285
335
  async function readNodes(directory) {
286
336
  await assertDirectory(directory);
@@ -296,17 +346,26 @@ async function readNodes(directory) {
296
346
  }
297
347
  const stored = [];
298
348
  const sequences = new Set();
299
- for (let index = 0; index < names.length; index++) {
300
- const name = names[index];
301
- const id = Number(NODE_NAME.exec(name)?.[1]);
302
- if (id !== index + 1)
303
- throw new Error("session conversation nodes are not contiguous");
304
- const decoded = decodeNode(await readJson(path.join(directory, name), SESSION_FILE_LIMITS.nodeBytes));
305
- if (decoded.node.id !== id || sequences.has(decoded.sequence)) {
306
- throw new Error("session conversation node identity is invalid");
349
+ for (let start = 0; start < names.length; start += NODE_READ_CONCURRENCY) {
350
+ const decoded = await Promise.all(names.slice(start, start + NODE_READ_CONCURRENCY)
351
+ .map(async (name, offset) => {
352
+ const id = Number(NODE_NAME.exec(name)?.[1]);
353
+ if (id !== start + offset + 1) {
354
+ throw new Error("session conversation nodes are not contiguous");
355
+ }
356
+ const entry = decodeNode(await readJson(path.join(directory, name), SESSION_FILE_LIMITS.nodeBytes));
357
+ if (entry.node.id !== id) {
358
+ throw new Error("session conversation node identity is invalid");
359
+ }
360
+ return entry;
361
+ }));
362
+ for (const entry of decoded) {
363
+ if (sequences.has(entry.sequence)) {
364
+ throw new Error("session conversation node identity is invalid");
365
+ }
366
+ sequences.add(entry.sequence);
367
+ stored.push(entry);
307
368
  }
308
- sequences.add(decoded.sequence);
309
- stored.push(decoded);
310
369
  }
311
370
  return stored;
312
371
  }
package/dist/settings.js CHANGED
@@ -1,5 +1,4 @@
1
1
  // Persistent, non-secret defaults for interactive and batch sessions.
2
- import { readFileSync } from "node:fs";
3
2
  import { chmod, mkdir } from "node:fs/promises";
4
3
  import * as path from "node:path";
5
4
  import { atomicWrite } from "./atomic.js";
@@ -9,6 +8,7 @@ import { providerNames } from "./providers/index.js";
9
8
  import { parseOllamaEndpoint } from "./providers/ollama-endpoint.js";
10
9
  import { withStoreLock } from "./store-lock.js";
11
10
  import { userDataLabel, userDataPath } from "./user-data.js";
11
+ import { assertStoreText, readBoundedJsonSync, USER_STORE_LIMITS } from "./user-store.js";
12
12
  export { EFFORTS } from "./effort.js";
13
13
  let saved;
14
14
  export function readSettings() {
@@ -24,7 +24,9 @@ export async function updateSettings(patch) {
24
24
  await chmod(directory, 0o700);
25
25
  return withStoreLock(file, async () => {
26
26
  const next = normalize({ ...readStore(file), ...patch });
27
- await atomicWrite(file, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
27
+ const text = `${JSON.stringify(next, null, 2)}\n`;
28
+ assertStoreText(text, USER_STORE_LIMITS.settingsBytes);
29
+ await atomicWrite(file, text, { mode: 0o600 });
28
30
  saved = next;
29
31
  return file;
30
32
  });
@@ -41,7 +43,7 @@ export function reloadSettings() {
41
43
  }
42
44
  function readStore(file = settingsPath()) {
43
45
  try {
44
- return normalize(JSON.parse(readFileSync(file, "utf8")));
46
+ return normalize(readBoundedJsonSync(file, USER_STORE_LIMITS.settingsBytes));
45
47
  }
46
48
  catch {
47
49
  // Missing, unreadable, and malformed stores all fall back safely. A bad
@@ -73,14 +75,14 @@ function normalize(value) {
73
75
  function modelsOf(value, providers) {
74
76
  if (!record(value))
75
77
  return undefined;
76
- const models = Object.fromEntries(Object.entries(value).filter((entry) => providers.includes(entry[0]) && typeof entry[1] === "string" && entry[1].trim() !== ""));
78
+ const models = Object.fromEntries(Object.entries(value).filter((entry) => providers.includes(entry[0]) && boundedNonempty(entry[1], USER_STORE_LIMITS.model)));
77
79
  return Object.keys(models).length === 0 ? undefined : models;
78
80
  }
79
81
  function member(value, values) {
80
82
  return typeof value === "string" && values.includes(value) ? value : undefined;
81
83
  }
82
84
  function endpoint(value) {
83
- if (typeof value !== "string")
85
+ if (!boundedNonempty(value, USER_STORE_LIMITS.endpoint))
84
86
  return undefined;
85
87
  try {
86
88
  return parseOllamaEndpoint(value).baseUrl;
@@ -89,6 +91,9 @@ function endpoint(value) {
89
91
  return undefined;
90
92
  }
91
93
  }
94
+ function boundedNonempty(value, max) {
95
+ return typeof value === "string" && value.length <= max && value.trim() !== "";
96
+ }
92
97
  function positiveInteger(value) {
93
98
  return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined;
94
99
  }
package/dist/start.js CHANGED
@@ -76,14 +76,24 @@ export async function start(args = process.argv.slice(2), environment = {}) {
76
76
  }
77
77
  }
78
78
  try {
79
- await (environment.runInteractive ?? runApp)(session, transcriptRoot);
79
+ if (environment.runInteractive === undefined) {
80
+ await runApp(session, transcriptRoot, { shutdownSignal: environment.signal });
81
+ }
82
+ else {
83
+ await environment.runInteractive(session, transcriptRoot, environment.signal);
84
+ }
80
85
  }
81
86
  finally {
82
87
  await session.persistence?.close();
83
88
  }
84
89
  }
85
90
  else {
86
- await (environment.runNonInteractive ?? runBatch)(session);
91
+ if (environment.runNonInteractive === undefined) {
92
+ await runBatch(session, { signal: environment.signal });
93
+ }
94
+ else {
95
+ await environment.runNonInteractive(session, environment.signal);
96
+ }
87
97
  }
88
98
  }
89
99
  function applyResumedSession(session, conversation, persistence) {
@@ -1,5 +1,7 @@
1
1
  // Shared grapheme boundaries for every projection of user-visible text.
2
2
  const SEGMENTER = new Intl.Segmenter(undefined, { granularity: "grapheme" });
3
+ /** Shared persisted-text and user-prompt boundary, measured like String.length. */
4
+ export const MAX_TEXT_CODE_UNITS = 1_048_576;
3
5
  export function segmentGraphemes(text) {
4
6
  return SEGMENTER.segment(text);
5
7
  }
@@ -1,4 +1,5 @@
1
1
  // Keyboard and pointer intent for the TUI shell.
2
+ import { PROMPT_LIMIT_MESSAGE, PromptLimitError } from "../input-boundary.js";
2
3
  import { activate as activateCompletion, move as moveCompletion, selected as selectedCompletion, } from "./complete.js";
3
4
  import * as edit from "./editor.js";
4
5
  import { turnBlocker } from "./feedback.js";
@@ -21,6 +22,13 @@ export function appInput(options) {
21
22
  }
22
23
  if (state.feedback !== undefined)
23
24
  feedback.dismiss();
25
+ if (key.name === "input_limit") {
26
+ if (state.open === undefined)
27
+ rejectPrompt();
28
+ else
29
+ showInputLimit();
30
+ return;
31
+ }
24
32
  // Detail expansion remains available while an approval is open. A large
25
33
  // diff may be compacted, but the user must be able to inspect it before
26
34
  // answering the permission prompt.
@@ -33,6 +41,8 @@ export function appInput(options) {
33
41
  if (state.open !== undefined) {
34
42
  const outcome = overlay.handle(state.open, key);
35
43
  state.open = outcome.open;
44
+ if (outcome.inputLimit === true)
45
+ showInputLimit();
36
46
  if (outcome.abort === true)
37
47
  state.activity?.control.abort(new Error("interrupted"));
38
48
  if (outcome.quit === true)
@@ -61,8 +71,10 @@ export function appInput(options) {
61
71
  case "enter": {
62
72
  if (state.completing !== undefined) {
63
73
  const completed = selectedCompletion(state.completing);
64
- if (completed !== undefined)
74
+ if (completed !== undefined) {
65
75
  state.editor = edit.of(completed);
76
+ state.promptRejected = false;
77
+ }
66
78
  state.completing = undefined;
67
79
  }
68
80
  submit();
@@ -75,6 +87,7 @@ export function appInput(options) {
75
87
  const completed = completion === undefined ? undefined : selectedCompletion(completion);
76
88
  if (completed !== undefined) {
77
89
  state.editor = edit.of(completed);
90
+ state.promptRejected = false;
78
91
  state.completing = undefined;
79
92
  }
80
93
  return;
@@ -106,16 +119,26 @@ export function appInput(options) {
106
119
  options.invalidate();
107
120
  return;
108
121
  }
109
- const edited = applyKey(state.editor, key);
110
- if (edited !== undefined) {
111
- state.editor = edited;
112
- state.completing = state.activity === undefined ? activateCompletion(edited.text) : undefined;
122
+ try {
123
+ const edited = applyKey(state.editor, key);
124
+ if (edited !== undefined) {
125
+ if (edited.text !== state.editor.text)
126
+ state.promptRejected = false;
127
+ state.editor = edited;
128
+ state.completing = state.activity === undefined ? activateCompletion(edited.text) : undefined;
129
+ }
130
+ }
131
+ catch (error) {
132
+ if (!(error instanceof PromptLimitError))
133
+ throw error;
134
+ rejectPrompt();
113
135
  }
114
136
  }
115
137
  function recall(step) {
116
138
  if (state.past.length === 0)
117
139
  return;
118
140
  state.completing = undefined;
141
+ state.promptRejected = false;
119
142
  if (state.recall === -1) {
120
143
  if (step > 0)
121
144
  return;
@@ -132,6 +155,10 @@ export function appInput(options) {
132
155
  state.editor = edit.of(state.past[state.recall]);
133
156
  }
134
157
  function submit() {
158
+ if (state.promptRejected) {
159
+ keep(PROMPT_LIMIT_MESSAGE);
160
+ return;
161
+ }
135
162
  const text = state.editor.text.trim();
136
163
  if (text === "")
137
164
  return;
@@ -171,6 +198,7 @@ export function appInput(options) {
171
198
  state.recall = -1;
172
199
  state.draft = "";
173
200
  state.completing = undefined;
201
+ state.promptRejected = false;
174
202
  state.past.push(text);
175
203
  state.scroll = 0;
176
204
  state.follow = true;
@@ -179,5 +207,12 @@ export function appInput(options) {
179
207
  function keep(text) {
180
208
  feedback.show({ text: `${text} · prompt kept`, tone: "warn", timeoutMs: 4_000 });
181
209
  }
210
+ function rejectPrompt() {
211
+ state.promptRejected = true;
212
+ keep(PROMPT_LIMIT_MESSAGE);
213
+ }
214
+ function showInputLimit() {
215
+ feedback.show({ text: PROMPT_LIMIT_MESSAGE, tone: "warn", timeoutMs: 4_000 });
216
+ }
182
217
  return { handle };
183
218
  }
@@ -13,5 +13,6 @@ export function appState() {
13
13
  draft: "",
14
14
  closeWhenIdle: false,
15
15
  committedNodeId: 0,
16
+ promptRejected: false,
16
17
  };
17
18
  }
@@ -212,6 +212,7 @@ export function appWorkflows(options) {
212
212
  nodeId: nodeId ?? prospectiveNodeId,
213
213
  coveredMessages: context?.messageCount ?? 0,
214
214
  lastInputTokens: Math.max(session.usage.lastInputTokens, request.inputTokens),
215
+ estimatedInputTokens: request.inputTokens,
215
216
  signal: activity.control.signal,
216
217
  force,
217
218
  policy: request.policy,
package/dist/tui/app.js CHANGED
@@ -42,6 +42,7 @@ export async function runApp(session, transcriptRoot, environment = {}) {
42
42
  let escapeTimer;
43
43
  let stopResize = () => { };
44
44
  let stopInput = () => { };
45
+ let stopShutdown = () => { };
45
46
  let failure;
46
47
  let activeWorkflow;
47
48
  // Timers outlive the teardown they were scheduled before. Painting after the
@@ -158,6 +159,7 @@ export async function runApp(session, transcriptRoot, environment = {}) {
158
159
  live = false;
159
160
  safely(stopInput);
160
161
  safely(stopResize);
162
+ safely(stopShutdown);
161
163
  if (activityTimer !== undefined)
162
164
  clearInterval(activityTimer);
163
165
  if (frameTimer !== undefined)
@@ -194,15 +196,15 @@ export async function runApp(session, transcriptRoot, environment = {}) {
194
196
  activeWorkflow = tracked;
195
197
  return tracked;
196
198
  }
197
- function requestQuit() {
199
+ function requestQuit(reason = new Error("interrupted")) {
200
+ state.open = overlay.cancel(state.open);
198
201
  const activity = state.activity;
199
202
  if (activity === undefined) {
200
203
  quit();
201
204
  return;
202
205
  }
203
206
  state.closeWhenIdle = true;
204
- state.open = overlay.cancel(state.open);
205
- activity.control.abort(new Error("interrupted"));
207
+ activity.control.abort(reason);
206
208
  }
207
209
  function startActivity(kind, label) {
208
210
  if (state.activity !== undefined)
@@ -305,6 +307,12 @@ export async function runApp(session, transcriptRoot, environment = {}) {
305
307
  }
306
308
  }
307
309
  try {
310
+ const shutdownSignal = environment.shutdownSignal;
311
+ const onShutdown = () => requestQuit(shutdownSignal?.reason);
312
+ if (shutdownSignal?.aborted === true)
313
+ return;
314
+ shutdownSignal?.addEventListener("abort", onShutdown, { once: true });
315
+ stopShutdown = () => shutdownSignal?.removeEventListener("abort", onShutdown);
308
316
  terminal.enter(session.config.reducedMotion);
309
317
  stopResize = terminal.onResize(() => guard(() => {
310
318
  paint.invalidate();
@@ -11,12 +11,10 @@ export function render(block, width, pal, context = {}) {
11
11
  case "reasoning":
12
12
  return renderReasoning(block, width, pal, {
13
13
  continues: context.previous?.kind === "reasoning",
14
- followsTool: context.previous?.kind === "tool",
15
14
  });
16
15
  case "tool":
17
16
  return renderTool(block, width, pal, {
18
- continues: context.previous?.kind === "tool" || context.previous?.kind === "reasoning",
19
- followsReasoning: context.previous?.kind === "reasoning",
17
+ continues: context.previous?.kind === "tool",
20
18
  now: context.now,
21
19
  motion: context.motion,
22
20
  reducedMotion: context.reducedMotion,