@giovannijecha/jecode 0.7.3 → 0.8.0

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 (54) hide show
  1. package/README.md +104 -29
  2. package/dist/atomic.js +14 -0
  3. package/dist/batch.js +5 -2
  4. package/dist/cli-info.js +1 -1
  5. package/dist/config.js +4 -1
  6. package/dist/context/policy.js +14 -7
  7. package/dist/controller-request.js +1 -1
  8. package/dist/controller.js +39 -15
  9. package/dist/duration.js +8 -0
  10. package/dist/oauth-http.js +2 -1
  11. package/dist/openai-account.js +12 -8
  12. package/dist/openai-oauth-callback.js +2 -1
  13. package/dist/providers/anthropic-stream.js +8 -0
  14. package/dist/providers/anthropic-wire.js +22 -10
  15. package/dist/providers/http.js +4 -3
  16. package/dist/providers/ollama-stream.js +7 -0
  17. package/dist/providers/ollama-wire.js +11 -3
  18. package/dist/providers/openai-codex.js +0 -2
  19. package/dist/providers/openai-stream.js +57 -2
  20. package/dist/providers/openai-wire.js +27 -12
  21. package/dist/providers/sse.js +115 -38
  22. package/dist/sessions/codec.js +13 -5
  23. package/dist/sessions/store.js +2 -1
  24. package/dist/settings-command.js +0 -5
  25. package/dist/settings.js +0 -2
  26. package/dist/steering.js +58 -0
  27. package/dist/text-boundary.js +47 -0
  28. package/dist/timeline.js +2 -1
  29. package/dist/tools/args.js +1 -1
  30. package/dist/tools/fs.js +5 -0
  31. package/dist/tools/text-boundary.js +1 -31
  32. package/dist/transcript.js +6 -1
  33. package/dist/tui/activity.js +20 -5
  34. package/dist/tui/app-input.js +31 -6
  35. package/dist/tui/app-workflows.js +57 -14
  36. package/dist/tui/app.js +18 -3
  37. package/dist/tui/blocks.js +7 -2
  38. package/dist/tui/components/messages.js +20 -17
  39. package/dist/tui/components/misc.js +7 -6
  40. package/dist/tui/components/status.js +12 -1
  41. package/dist/tui/components/tool.js +152 -50
  42. package/dist/tui/help.js +1 -1
  43. package/dist/tui/motion.js +32 -0
  44. package/dist/tui/session-view.js +3 -2
  45. package/dist/tui/transcript-grammar.js +25 -0
  46. package/dist/tui/transcript-view.js +132 -7
  47. package/dist/tui/turn.js +78 -39
  48. package/dist/tui/view.js +4 -2
  49. package/dist/ui/diff.js +51 -16
  50. package/dist/ui/render.js +17 -27
  51. package/dist/ui/theme.js +19 -18
  52. package/dist/ui/width.js +31 -22
  53. package/dist/usage.js +5 -1
  54. package/package.json +1 -1
package/dist/tui/turn.js CHANGED
@@ -3,13 +3,14 @@
3
3
  // The controller speaks in stream events and tool results; the screen speaks in
4
4
  // blocks. This is the whole of the translation, kept out of the shell so that
5
5
  // neither has to know how the other is built.
6
+ import { graphemes } from "../text-boundary.js";
6
7
  import { condense, diff } from "../ui/diff.js";
7
8
  import { promptFor } from "./approve.js";
8
9
  // Semantic activity labels feed the footer's compact state and timer while
9
10
  // reasoning and tools keep the detailed work visible in the transcript.
10
11
  const WAITING = "Waiting";
11
12
  const THINKING = "Thinking";
12
- const WRITING = "Writing";
13
+ const RESPONDING = "Responding";
13
14
  const ASKING = "Waiting for you";
14
15
  /** Unchanged rows kept either side of a change. */
15
16
  const CONTEXT = 2;
@@ -18,10 +19,7 @@ export function transcribe(stage) {
18
19
  // one, which is what keeps reasoning and answer from running together.
19
20
  let open;
20
21
  const tools = new Map();
21
- let step = 1;
22
- let steps = 1;
23
- let tool = 1;
24
- let toolTotal = 1;
22
+ const progress = new Map();
25
23
  const close = () => {
26
24
  const block = open?.block;
27
25
  const changed = block?.kind === "reasoning" && block.live === true;
@@ -40,30 +38,50 @@ export function transcribe(stage) {
40
38
  continue;
41
39
  block.tone = "fail";
42
40
  block.right = reason ?? "failed";
43
- block.startedAt = undefined;
41
+ settleDuration(block);
44
42
  stage.render(block);
45
43
  }
46
44
  },
47
- onStep(current, total) {
48
- step = current;
49
- steps = total;
50
- stage.status(waiting(step, steps));
45
+ onToolPreparing(call, current, total) {
46
+ progress.set(call.id, { current, total });
47
+ const changed = close();
48
+ if (changed !== undefined)
49
+ stage.render(changed);
50
+ stage.status(toolStatus("Preparing", call, progress));
51
51
  stage.render();
52
52
  },
53
- onToolProgress(current, total) {
54
- tool = current;
55
- toolTotal = total;
53
+ onToolStart(call, current, total) {
54
+ progress.set(call.id, { current, total });
55
+ stage.status(toolStatus("Running", call, progress));
56
+ const block = tools.get(call.id);
57
+ if (block !== undefined && block.kind === "tool") {
58
+ block.right = "running";
59
+ block.startedAt = Date.now();
60
+ block.durationMs = undefined;
61
+ }
62
+ stage.render(block);
56
63
  },
57
64
  onUsage(usage) {
58
65
  stage.usage?.(usage);
59
66
  },
67
+ onRequestInput(inputTokens) {
68
+ stage.requestInput?.(inputTokens);
69
+ },
60
70
  onStatus(status) {
61
71
  stage.status(status);
62
72
  stage.render();
63
73
  },
64
74
  onStream(event) {
75
+ if (event.kind === "tool") {
76
+ const changed = close();
77
+ if (changed !== undefined)
78
+ stage.render(changed);
79
+ stage.status(`Preparing ${event.name ?? "tool"}`);
80
+ stage.render();
81
+ return;
82
+ }
65
83
  const kind = event.kind === "thinking" ? "reasoning" : "answer";
66
- stage.status(kind === "reasoning" ? THINKING : WRITING);
84
+ stage.status(kind === "reasoning" ? THINKING : RESPONDING);
67
85
  if (open === undefined || open.kind !== kind) {
68
86
  const changed = close();
69
87
  if (changed !== undefined)
@@ -83,15 +101,13 @@ export function transcribe(stage) {
83
101
  const changed = close();
84
102
  if (changed !== undefined)
85
103
  stage.render(changed);
86
- stage.status(`Running ${call.name}${toolTotal > 1 ? ` · tool ${tool}/${toolTotal}` : ""}${step > 1 ? ` · step ${step}/${steps}` : ""}`);
87
104
  const block = {
88
105
  kind: "tool",
89
106
  name: call.name,
90
107
  target: target(call.input),
91
- right: "running",
108
+ right: "ready",
92
109
  tone: "pending",
93
110
  body: preview(call, look),
94
- startedAt: Date.now(),
95
111
  };
96
112
  tools.set(call.id, block);
97
113
  stage.emit(block);
@@ -105,7 +121,7 @@ export function transcribe(stage) {
105
121
  stage.render(block);
106
122
  },
107
123
  onToolResult(call, result, summary) {
108
- stage.status(waiting(step, steps));
124
+ stage.status(WAITING);
109
125
  const block = tools.get(call.id);
110
126
  if (block === undefined || block.kind !== "tool")
111
127
  return;
@@ -118,7 +134,7 @@ export function transcribe(stage) {
118
134
  }
119
135
  block.tone = result.isError ? "fail" : "ok";
120
136
  block.right = summary ?? "";
121
- block.startedAt = undefined;
137
+ settleDuration(block);
122
138
  // A failure replaces the preview: what the call was going to do stops
123
139
  // being the interesting part the moment it did not do it.
124
140
  const outcome = result.isError
@@ -130,14 +146,8 @@ export function transcribe(stage) {
130
146
  },
131
147
  approve(call) {
132
148
  const block = tools.get(call.id);
133
- if (stage.approved(call)) {
134
- if (block !== undefined && block.kind === "tool") {
135
- block.right = "running";
136
- block.startedAt ??= Date.now();
137
- stage.render(block);
138
- }
149
+ if (stage.approved(call))
139
150
  return Promise.resolve(true);
140
- }
141
151
  const changed = close();
142
152
  if (changed !== undefined)
143
153
  stage.render(changed);
@@ -156,8 +166,8 @@ export function transcribe(stage) {
156
166
  if (settled !== undefined && settled.kind === "tool") {
157
167
  if (approved) {
158
168
  settled.tone = "pending";
159
- settled.right = "running";
160
- settled.startedAt = Date.now();
169
+ settled.right = "ready";
170
+ settled.startedAt = undefined;
161
171
  }
162
172
  else {
163
173
  settled.tone = "deny";
@@ -165,7 +175,9 @@ export function transcribe(stage) {
165
175
  settled.startedAt = undefined;
166
176
  }
167
177
  }
168
- stage.status(approved ? `Running ${call.name}` : waiting(step, steps));
178
+ stage.status(approved
179
+ ? toolStatus("Preparing", call, progress)
180
+ : WAITING);
169
181
  stage.render(settled);
170
182
  resolve(approved);
171
183
  });
@@ -173,14 +185,24 @@ export function transcribe(stage) {
173
185
  },
174
186
  };
175
187
  }
188
+ function toolStatus(phase, call, progress) {
189
+ const position = progress.get(call.id);
190
+ const tool = position !== undefined && position.total > 1
191
+ ? ` · tool ${position.current}/${position.total}`
192
+ : "";
193
+ return `${phase} ${call.name}${tool}`;
194
+ }
176
195
  function pendingApproval(body) {
177
196
  const added = body?.filter((detail) => detail.kind === "add").length ?? 0;
178
197
  const removed = body?.filter((detail) => detail.kind === "del").length ?? 0;
179
198
  const summary = added + removed === 0 ? "" : `+${added} −${removed} · `;
180
199
  return `${summary}pending approval`;
181
200
  }
182
- function waiting(step, total) {
183
- return step === 1 ? WAITING : `${WAITING} · step ${step}/${total}`;
201
+ function settleDuration(block) {
202
+ if (block.startedAt !== undefined) {
203
+ block.durationMs = Math.max(0, Date.now() - block.startedAt);
204
+ }
205
+ block.startedAt = undefined;
184
206
  }
185
207
  /** The argument worth showing: the thing the call acts on. */
186
208
  function target(input) {
@@ -249,18 +271,35 @@ function emphasizePairs(rows) {
249
271
  continue;
250
272
  if (rows[index - 1]?.kind === "del" || rows[index + 2]?.kind === "add")
251
273
  continue;
274
+ const removedClusters = graphemes(removed.text);
275
+ const addedClusters = graphemes(added.text);
276
+ let prefix = 0;
252
277
  let start = 0;
253
- while (start < removed.text.length && start < added.text.length && removed.text[start] === added.text[start])
254
- start++;
278
+ while (prefix < removedClusters.length &&
279
+ prefix < addedClusters.length &&
280
+ removedClusters[prefix] === addedClusters[prefix]) {
281
+ start += removedClusters[prefix].length;
282
+ prefix++;
283
+ }
255
284
  let suffix = 0;
256
- while (suffix < removed.text.length - start &&
257
- suffix < added.text.length - start &&
258
- removed.text[removed.text.length - 1 - suffix] === added.text[added.text.length - 1 - suffix])
285
+ let removedSuffix = 0;
286
+ let addedSuffix = 0;
287
+ while (suffix < removedClusters.length - prefix &&
288
+ suffix < addedClusters.length - prefix &&
289
+ removedClusters[removedClusters.length - 1 - suffix] ===
290
+ addedClusters[addedClusters.length - 1 - suffix]) {
291
+ removedSuffix += removedClusters[removedClusters.length - 1 - suffix].length;
292
+ addedSuffix += addedClusters[addedClusters.length - 1 - suffix].length;
259
293
  suffix++;
260
- while (suffix > 0 && (!wordBoundary(removed.text, suffix) || !wordBoundary(added.text, suffix)))
294
+ }
295
+ while (suffix > 0 &&
296
+ (!wordBoundary(removed.text, removedSuffix) || !wordBoundary(added.text, addedSuffix))) {
297
+ removedSuffix -= removedClusters[removedClusters.length - suffix].length;
298
+ addedSuffix -= addedClusters[addedClusters.length - suffix].length;
261
299
  suffix--;
262
- const removedLength = removed.text.length - start - suffix;
263
- const addedLength = added.text.length - start - suffix;
300
+ }
301
+ const removedLength = removed.text.length - start - removedSuffix;
302
+ const addedLength = added.text.length - start - addedSuffix;
264
303
  if (removedLength > 0)
265
304
  removed.emphasis = { start, length: removedLength };
266
305
  if (addedLength > 0)
package/dist/tui/view.js CHANGED
@@ -25,7 +25,7 @@ export function compose(view, size, transcript = transcriptRenderer()) {
25
25
  // Any spare height belongs above the conversation, so short sessions grow
26
26
  // upward from the fixed dock rhythm instead of leaving a changing hole
27
27
  // beneath the latest reply.
28
- const viewport = transcript.viewport(view.blocks, width, transcriptHeight, view.scroll, view.pal, { now: view.now });
28
+ const viewport = transcript.viewport(view.blocks, width, transcriptHeight, view.scroll, view.pal, { now: view.now, reducedMotion: view.reducedMotion });
29
29
  const cursor = dock.cursor === undefined
30
30
  ? undefined
31
31
  : { row: transcriptHeight + dock.cursor.row, col: dock.cursor.col };
@@ -34,6 +34,7 @@ export function compose(view, size, transcript = transcriptRenderer()) {
34
34
  cursor,
35
35
  maxScroll: viewport.maxScroll,
36
36
  transcriptPending: viewport.pending,
37
+ transcriptAnimating: viewport.animating,
37
38
  };
38
39
  }
39
40
  function dockRows(view, width, height) {
@@ -42,6 +43,7 @@ function dockRows(view, width, height) {
42
43
  // use the footer while that interaction is open instead of repeating a
43
44
  // generic "Running /…" label beside it.
44
45
  status: view.modal === undefined ? view.status : undefined,
46
+ steering: view.modal === undefined ? view.steering : undefined,
45
47
  feedback: view.feedback,
46
48
  readiness: view.readiness,
47
49
  unseen: view.unseen ?? 0,
@@ -87,5 +89,5 @@ function tooSmall(height, width, view) {
87
89
  { text: elide(`need ${MIN_COLS}×${MIN_ROWS}`, Math.max(1, width)), fg: view.pal.ink.dim },
88
90
  ]);
89
91
  }
90
- return { rows, maxScroll: 0, transcriptPending: false };
92
+ return { rows, maxScroll: 0, transcriptPending: false, transcriptAnimating: false };
91
93
  }
package/dist/ui/diff.js CHANGED
@@ -8,24 +8,37 @@
8
8
  * Above this many cells the table stops being worth building.
9
9
  *
10
10
  * The diff is quadratic in the two line counts, and it is drawn between two
11
- * keystrokes. Past the ceiling the honest answer is the coarse one — all of
12
- * the old, then all of the new — rather than a frame the user waits for.
11
+ * keystrokes. Common edges are removed before this limit is applied, so a
12
+ * local edit in a large file stays local. If the changed middle itself crosses
13
+ * the ceiling, the honest answer is the coarse one rather than a frame the
14
+ * user waits for.
13
15
  */
14
16
  const CEILING = 250_000;
15
17
  export function diff(before, after) {
16
18
  const a = lines(before);
17
19
  const b = lines(after);
18
- if (a.length * b.length > CEILING) {
19
- return [...a.map(del), ...b.map(add)];
20
+ const prefix = commonPrefix(a, b);
21
+ const suffix = commonSuffix(a, b, prefix);
22
+ const aEnd = a.length - suffix;
23
+ const bEnd = b.length - suffix;
24
+ const middleA = a.slice(prefix, aEnd);
25
+ const middleB = b.slice(prefix, bEnd);
26
+ const rows = a.slice(0, prefix).map(keep);
27
+ if (middleA.length * middleB.length > CEILING) {
28
+ for (const line of middleA)
29
+ rows.push(del(line));
30
+ for (const line of middleB)
31
+ rows.push(add(line));
32
+ appendSuffix(rows, a, aEnd);
33
+ return rows;
20
34
  }
21
- const table = common(a, b);
22
- const rows = [];
35
+ const table = common(middleA, middleB);
23
36
  let i = 0;
24
37
  let j = 0;
25
- const width = b.length + 1;
26
- while (i < a.length && j < b.length) {
27
- if (a[i] === b[j]) {
28
- rows.push({ kind: "keep", text: a[i] });
38
+ const width = middleB.length + 1;
39
+ while (i < middleA.length && j < middleB.length) {
40
+ if (middleA[i] === middleB[j]) {
41
+ rows.push(keep(middleA[i]));
29
42
  i++;
30
43
  j++;
31
44
  continue;
@@ -33,20 +46,39 @@ export function diff(before, after) {
33
46
  // Deletions first on a tie, so a replaced line reads old-then-new — the
34
47
  // order the eye expects, and the order every other diff prints.
35
48
  if (table[(i + 1) * width + j] >= table[i * width + j + 1]) {
36
- rows.push(del(a[i]));
49
+ rows.push(del(middleA[i]));
37
50
  i++;
38
51
  }
39
52
  else {
40
- rows.push(add(b[j]));
53
+ rows.push(add(middleB[j]));
41
54
  j++;
42
55
  }
43
56
  }
44
- while (i < a.length)
45
- rows.push(del(a[i++]));
46
- while (j < b.length)
47
- rows.push(add(b[j++]));
57
+ while (i < middleA.length)
58
+ rows.push(del(middleA[i++]));
59
+ while (j < middleB.length)
60
+ rows.push(add(middleB[j++]));
61
+ appendSuffix(rows, a, aEnd);
48
62
  return rows;
49
63
  }
64
+ function commonPrefix(a, b) {
65
+ const limit = Math.min(a.length, b.length);
66
+ let length = 0;
67
+ while (length < limit && a[length] === b[length])
68
+ length++;
69
+ return length;
70
+ }
71
+ function commonSuffix(a, b, prefix) {
72
+ const limit = Math.min(a.length, b.length) - prefix;
73
+ let length = 0;
74
+ while (length < limit && a[a.length - length - 1] === b[b.length - length - 1])
75
+ length++;
76
+ return length;
77
+ }
78
+ function appendSuffix(rows, source, start) {
79
+ for (let index = start; index < source.length; index++)
80
+ rows.push(keep(source[index]));
81
+ }
50
82
  /**
51
83
  * Drop the unchanged stretches, keeping `context` rows either side.
52
84
  *
@@ -109,3 +141,6 @@ function add(text) {
109
141
  function del(text) {
110
142
  return { kind: "del", text };
111
143
  }
144
+ function keep(text) {
145
+ return { kind: "keep", text };
146
+ }
package/dist/ui/render.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // Composing a terminal row out of styled segments. Pure: every function here
2
2
  // returns a string or rows of segments, none of them write anywhere.
3
3
  import { terminalText } from "./terminal-text.js";
4
- import { elide, textWidth, wrapText } from "./width.js";
4
+ import { elide, splitByCells, textWidth, wrapText } from "./width.js";
5
5
  // Built rather than written literally: a raw escape byte in the source is
6
6
  // invisible in a diff and in a code review, which is how they survive.
7
7
  const ESC = String.fromCharCode(27);
@@ -125,9 +125,7 @@ export function blank(width, ground) {
125
125
  /**
126
126
  * A full-width divider.
127
127
  *
128
- * Edge to edge, because the other thing that spans a whole row is the ground
129
- * behind a user's message: two elements that stop at different columns read as
130
- * two grids, and the eye finds the discrepancy before it finds the text.
128
+ * Edge to edge so the composer and dock keep one exact terminal boundary.
131
129
  */
132
130
  export function rule(width, color) {
133
131
  return paint({ text: "─".repeat(Math.max(0, width)), fg: color });
@@ -166,13 +164,13 @@ export function flow(segs, max, continuation = []) {
166
164
  let used = 0;
167
165
  let pending;
168
166
  const room = () => Math.max(1, max - (rows.length === 0 ? 0 : lead));
169
- const add = (tok) => {
167
+ const add = (tok, width = textWidth(tok.text)) => {
170
168
  const last = current[current.length - 1];
171
169
  if (last !== undefined && sameStyle(last, tok.seg))
172
170
  last.text += tok.text;
173
171
  else
174
172
  current.push({ ...tok.seg, text: tok.text });
175
- used += textWidth(tok.text);
173
+ used += width;
176
174
  };
177
175
  const flush = () => {
178
176
  rows.push(rows.length === 0 ? current : [...continuation.map(copy), ...current]);
@@ -191,39 +189,31 @@ export function flow(segs, max, continuation = []) {
191
189
  continue;
192
190
  }
193
191
  const space = pending === undefined ? 0 : textWidth(pending.text);
194
- let word = tok.text;
195
- if (used + space + textWidth(word) > room() && current.length > 0)
192
+ const wordWidth = textWidth(tok.text);
193
+ if (used + space + wordWidth > room() && current.length > 0)
196
194
  flush();
197
195
  else if (pending !== undefined) {
198
- add(pending);
196
+ add(pending, space);
199
197
  pending = undefined;
200
198
  }
201
199
  // A word wider than any row is spent across rows: with autowrap off, what
202
200
  // overflows is not ugly, it is gone.
203
- while (textWidth(word) > room() - used) {
204
- const head = clipTo(word, room() - used);
205
- if (head === "")
206
- break;
207
- add({ ...tok, text: head });
208
- word = word.slice(head.length);
209
- flush();
201
+ if (wordWidth > room() - used) {
202
+ const chunks = splitByCells(tok.text, room() - used, Math.max(1, max - lead));
203
+ for (const [index, chunk] of chunks.entries()) {
204
+ add({ ...tok, text: chunk.text }, chunk.width);
205
+ if (index + 1 < chunks.length)
206
+ flush();
207
+ }
208
+ continue;
210
209
  }
211
- if (word !== "")
212
- add({ ...tok, text: word });
210
+ if (tok.text !== "")
211
+ add(tok, wordWidth);
213
212
  }
214
213
  if (current.length > 0 || rows.length === 0)
215
214
  flush();
216
215
  return rows;
217
216
  }
218
- function clipTo(text, cols) {
219
- let out = "";
220
- for (const char of text) {
221
- if (textWidth(out + char) > cols)
222
- break;
223
- out += char;
224
- }
225
- return out;
226
- }
227
217
  function copy(seg) {
228
218
  return { ...seg };
229
219
  }
package/dist/ui/theme.js CHANGED
@@ -1,26 +1,27 @@
1
1
  // Jecode's fixed terminal identity. Components depend on semantic tokens, not
2
- // literal colours, while the product exposes one deliberate dark Steel look.
3
- // Jecode's fixed dark Steel baseline. Structural blues, semantic outcomes,
4
- // and slate surfaces keep the transcript vivid without turning it decorative.
2
+ // literal colours, while the product exposes one deliberate dark Slate look.
3
+ // Jecode's fixed dark Slate baseline. The exported name stays stable while
4
+ // cooler, quieter values leave the transcript bright enough to read and let
5
+ // live state carry the colour.
5
6
  // Components depend on these roles rather than embedding presentation values.
6
7
  export const STEEL = {
7
- accent: [102, 155, 210],
8
- technical: [78, 201, 232],
9
- focus: [102, 155, 210],
10
- rule: [53, 80, 110],
8
+ accent: [124, 164, 222],
9
+ technical: [126, 186, 208],
10
+ focus: [124, 164, 222],
11
+ rule: [44, 60, 78],
11
12
  ink: {
12
- fg: [220, 224, 229],
13
- bright: [235, 239, 244],
14
- muted: [156, 169, 183],
15
- dim: [112, 124, 137],
16
- attention: [230, 191, 95],
17
- added: [134, 203, 146],
18
- removed: [232, 112, 112],
13
+ fg: [214, 219, 226],
14
+ bright: [241, 244, 248],
15
+ muted: [149, 160, 174],
16
+ dim: [99, 111, 125],
17
+ attention: [226, 188, 112],
18
+ added: [138, 190, 150],
19
+ removed: [223, 120, 120],
19
20
  },
20
21
  surface: {
21
- subtle: [31, 38, 47],
22
- added: [22, 55, 34],
23
- removed: [62, 24, 27],
24
- attention: [62, 50, 19],
22
+ subtle: [23, 29, 37],
23
+ added: [21, 52, 33],
24
+ removed: [58, 24, 27],
25
+ attention: [58, 47, 20],
25
26
  },
26
27
  };
package/dist/ui/width.js CHANGED
@@ -6,6 +6,8 @@
6
6
  // cell at all. Every alignment in the UI depends on this file being right:
7
7
  // the right-hand column, a ground band, the cursor. Nothing measures with
8
8
  // `.length`.
9
+ import { graphemes, segmentGraphemes } from "../text-boundary.js";
10
+ export { graphemes } from "../text-boundary.js";
9
11
  /** Ranges the terminal draws two cells wide (East Asian Wide and Fullwidth). */
10
12
  const WIDE = [
11
13
  [0x1100, 0x115f],
@@ -93,16 +95,6 @@ function inRanges(code, ranges) {
93
95
  // in source is a byte nobody reviews.
94
96
  const VS15 = String.fromCodePoint(0xfe0e);
95
97
  const VS16 = String.fromCodePoint(0xfe0f);
96
- // Grapheme segmentation is in the standard library, so a family emoji built
97
- // out of five code points and three joiners counts as the one thing the
98
- // terminal actually draws.
99
- const SEGMENTER = new Intl.Segmenter(undefined, { granularity: "grapheme" });
100
- export function graphemes(text) {
101
- const out = [];
102
- for (const { segment } of SEGMENTER.segment(text))
103
- out.push(segment);
104
- return out;
105
- }
106
98
  /**
107
99
  * Cells taken by one grapheme cluster.
108
100
  *
@@ -126,17 +118,38 @@ export function charWidth(cluster) {
126
118
  }
127
119
  export function textWidth(text) {
128
120
  let total = 0;
129
- for (const cluster of SEGMENTER.segment(text))
121
+ for (const cluster of segmentGraphemes(text))
130
122
  total += charWidth(cluster.segment);
131
123
  return total;
132
124
  }
125
+ /** Split text into grapheme-safe chunks without rescanning any suffix. */
126
+ export function splitByCells(text, firstCols, followingCols = firstCols) {
127
+ if (text === "")
128
+ return [{ text, width: 0 }];
129
+ const chunks = [];
130
+ let start = 0;
131
+ let used = 0;
132
+ let room = Math.max(1, firstCols);
133
+ for (const { index, segment } of segmentGraphemes(text)) {
134
+ const width = charWidth(segment);
135
+ if (index > start && used + width > room) {
136
+ chunks.push({ text: text.slice(start, index), width: used });
137
+ start = index;
138
+ used = 0;
139
+ room = Math.max(1, followingCols);
140
+ }
141
+ used += width;
142
+ }
143
+ chunks.push({ text: text.slice(start), width: used });
144
+ return chunks;
145
+ }
133
146
  /** The longest prefix of `text` that fits in `cols` cells. */
134
147
  export function clip(text, cols) {
135
148
  if (cols <= 0)
136
149
  return "";
137
150
  let out = "";
138
151
  let used = 0;
139
- for (const { segment } of SEGMENTER.segment(text)) {
152
+ for (const { segment } of segmentGraphemes(text)) {
140
153
  const w = charWidth(segment);
141
154
  if (used + w > cols)
142
155
  break;
@@ -214,17 +227,13 @@ export function wrapText(text, max, continuation = "") {
214
227
  flush();
215
228
  if (w > room()) {
216
229
  // Too long for any row: spend whole rows on it until it fits.
217
- let rest = word;
218
- while (textWidth(rest) > room()) {
219
- const head = clip(rest, room() - (line === "" ? 0 : width + 1));
220
- if (head === "")
221
- break;
222
- line = line === "" ? head : `${line} ${head}`;
223
- rest = rest.slice(head.length);
224
- flush();
230
+ const chunks = splitByCells(word, room(), Math.max(1, max - lead));
231
+ for (const [index, chunk] of chunks.entries()) {
232
+ line = chunk.text;
233
+ width = chunk.width;
234
+ if (index + 1 < chunks.length)
235
+ flush();
225
236
  }
226
- line = rest;
227
- width = textWidth(rest);
228
237
  continue;
229
238
  }
230
239
  line = line === "" ? word : `${line} ${word}`;
package/dist/usage.js CHANGED
@@ -12,9 +12,12 @@ export function emptyUsage() {
12
12
  }
13
13
  export function recordUsage(total, next) {
14
14
  total.requests += 1;
15
- total.lastInputTokens = next.inputTokens;
16
15
  addUsage(total, next);
17
16
  }
17
+ /** Replace context pressure without inventing provider-reported usage. */
18
+ export function recordRequestInput(total, inputTokens) {
19
+ total.lastInputTokens = inputTokens;
20
+ }
18
21
  /** Account for an internal request without replacing the main context signal. */
19
22
  export function recordAuxiliaryUsage(total, next) {
20
23
  total.requests += 1;
@@ -32,6 +35,7 @@ export function usageFromHistory(messages) {
32
35
  for (const message of messages) {
33
36
  if (message.role === "assistant" && message.usage !== undefined) {
34
37
  recordUsage(total, message.usage);
38
+ recordRequestInput(total, message.usage.inputTokens);
35
39
  }
36
40
  }
37
41
  return total;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@giovannijecha/jecode",
3
- "version": "0.7.3",
3
+ "version": "0.8.0",
4
4
  "description": "An owned coding agent with zero external runtime dependencies.",
5
5
  "license": "MIT",
6
6
  "repository": {