@giovannijecha/jecode 0.7.2 → 0.7.4

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 (43) hide show
  1. package/README.md +87 -23
  2. package/dist/atomic.js +14 -0
  3. package/dist/batch.js +26 -19
  4. package/dist/cli-info.js +1 -1
  5. package/dist/config.js +3 -2
  6. package/dist/context/budget.js +37 -0
  7. package/dist/context/compactor.js +9 -2
  8. package/dist/context/estimate.js +31 -0
  9. package/dist/context/policy.js +24 -14
  10. package/dist/controller-request.js +27 -4
  11. package/dist/controller.js +6 -4
  12. package/dist/conversation.js +48 -31
  13. package/dist/oauth-http.js +2 -1
  14. package/dist/openai-oauth-callback.js +2 -1
  15. package/dist/providers/anthropic.js +1 -1
  16. package/dist/providers/http.js +8 -5
  17. package/dist/providers/ollama.js +1 -1
  18. package/dist/providers/openai-codex.js +1 -3
  19. package/dist/providers/openai.js +1 -1
  20. package/dist/providers/sse.js +117 -40
  21. package/dist/providers/stream-limits.js +14 -2
  22. package/dist/sessions/store.js +2 -1
  23. package/dist/text-boundary.js +47 -0
  24. package/dist/timeline.js +2 -1
  25. package/dist/tools/fs.js +85 -38
  26. package/dist/tools/search.js +106 -44
  27. package/dist/tools/shell.js +14 -6
  28. package/dist/tools/text-boundary.js +7 -25
  29. package/dist/tui/app-state.js +0 -1
  30. package/dist/tui/app-workflows.js +40 -30
  31. package/dist/tui/app.js +12 -14
  32. package/dist/tui/blocks.js +0 -2
  33. package/dist/tui/components/messages.js +2 -5
  34. package/dist/tui/components/tool.js +11 -10
  35. package/dist/tui/session-view.js +2 -1
  36. package/dist/tui/transcript-view.js +178 -106
  37. package/dist/tui/turn.js +29 -8
  38. package/dist/tui/view.js +8 -3
  39. package/dist/ui/diff.js +51 -16
  40. package/dist/ui/render.js +16 -24
  41. package/dist/ui/width.js +31 -22
  42. package/dist/usage.js +5 -1
  43. package/package.json +2 -1
@@ -25,32 +25,25 @@ export class ConversationTree {
25
25
  return new ConversationTree([], 0);
26
26
  }
27
27
  static restore(nodes, activeNodeId) {
28
- let tree = ConversationTree.empty();
28
+ if (nodes.length > CONVERSATION_LIMITS.nodes) {
29
+ throw new Error("conversation reached its session limit — start /new");
30
+ }
31
+ const restored = [];
29
32
  for (let index = 0; index < nodes.length; index++) {
30
33
  const node = nodes[index];
31
34
  if (node === undefined || node.id !== index + 1) {
32
35
  throw new Error("session contains a non-sequential conversation node");
33
36
  }
34
- tree = tree.select(node.parentId).commit({
35
- parentId: node.parentId,
36
- createdAt: node.createdAt,
37
- identity: node.identity,
38
- messages: node.messages,
39
- blocks: node.blocks,
40
- ...(node.context === undefined ? {} : { context: node.context }),
41
- ...(node.failure === undefined ? {} : { failure: node.failure }),
42
- }, node.settlement);
43
- const restored = tree.activeNode;
44
- if (restored === undefined || restored.id !== node.id) {
45
- throw new Error("session conversation could not be restored");
46
- }
47
- if (node.revision > 1) {
48
- const copy = [...tree.#nodes];
49
- copy[node.id - 1] = ownedNode({ ...restored, revision: node.revision });
50
- tree = new ConversationTree(copy, node.id);
51
- }
37
+ const owned = ownedNode({ ...node, blocks: settledBlocks(node.blocks) });
38
+ assertTurn(owned);
39
+ assertPersistableNode(owned);
40
+ restored.push(owned);
52
41
  }
53
- return tree.select(activeNodeId);
42
+ assertBounds(restored);
43
+ if (!validNodeId(activeNodeId) ||
44
+ (activeNodeId !== 0 && restored[activeNodeId - 1]?.id !== activeNodeId))
45
+ throw new Error("conversation node does not exist");
46
+ return new ConversationTree(restored, activeNodeId);
54
47
  }
55
48
  /** Commit or extend the one prospective leaf turn. */
56
49
  commit(draft, settlement) {
@@ -236,12 +229,13 @@ function assertBounds(nodes) {
236
229
  let messageCodeUnits = 0;
237
230
  let transcriptCodeUnits = 0;
238
231
  let contextCodeUnits = 0;
232
+ const contextOwners = [];
239
233
  for (const node of nodes) {
240
234
  messageCodeUnits += JSON.stringify(node.messages).length;
241
235
  transcriptCodeUnits += JSON.stringify(node.blocks).length;
242
236
  contextCodeUnits += node.context?.summary.length ?? 0;
243
237
  if (node.context !== undefined)
244
- assertContextPath(nodes, node);
238
+ contextOwners.push(node);
245
239
  }
246
240
  if (messageCodeUnits > CONVERSATION_LIMITS.messageCodeUnits) {
247
241
  throw new Error("conversation model history reached its session limit — start /new");
@@ -252,18 +246,41 @@ function assertBounds(nodes) {
252
246
  if (contextCodeUnits > CONVERSATION_LIMITS.contextCodeUnits) {
253
247
  throw new Error("conversation context summaries reached their session limit — start /new");
254
248
  }
249
+ assertContextPaths(nodes, contextOwners);
255
250
  }
256
- function assertContextPath(nodes, owner) {
257
- const context = owner.context;
258
- const boundary = nodes[context.throughNodeId - 1];
259
- if (boundary === undefined || !validContextAnchor(context, boundary.messages.length)) {
260
- throw new Error("turn context checkpoint is invalid");
251
+ function assertContextPaths(nodes, owners) {
252
+ if (owners.length === 0)
253
+ return;
254
+ const children = Array.from({ length: nodes.length + 1 }, () => []);
255
+ for (const node of nodes)
256
+ children[node.parentId]?.push(node.id);
257
+ const entered = new Uint32Array(nodes.length + 1);
258
+ const exited = new Uint32Array(nodes.length + 1);
259
+ const stack = [{ id: 0, exit: false }];
260
+ let clock = 0;
261
+ while (stack.length > 0) {
262
+ const current = stack.pop();
263
+ if (current.exit) {
264
+ exited[current.id] = clock++;
265
+ continue;
266
+ }
267
+ entered[current.id] = clock++;
268
+ stack.push({ id: current.id, exit: true });
269
+ const descendants = children[current.id];
270
+ for (let index = descendants.length - 1; index >= 0; index--) {
271
+ stack.push({ id: descendants[index], exit: false });
272
+ }
273
+ }
274
+ for (const owner of owners) {
275
+ const context = owner.context;
276
+ const boundary = nodes[context.throughNodeId - 1];
277
+ if (boundary === undefined || !validContextAnchor(context, boundary.messages.length)) {
278
+ throw new Error("turn context checkpoint is invalid");
279
+ }
280
+ if (entered[boundary.id] > entered[owner.id] ||
281
+ exited[owner.id] > exited[boundary.id])
282
+ throw new Error("turn context checkpoint is outside its branch");
261
283
  }
262
- let id = owner.id;
263
- while (id !== 0 && id !== boundary.id)
264
- id = nodes[id - 1]?.parentId ?? 0;
265
- if (id !== boundary.id)
266
- throw new Error("turn context checkpoint is outside its branch");
267
284
  }
268
285
  function validNodeId(value) {
269
286
  return Number.isSafeInteger(value) && value >= 0;
@@ -2,6 +2,7 @@
2
2
  //
3
3
  // These requests never redirect, never retry, and never retain an unbounded
4
4
  // response. Authorization codes and refresh tokens must not leak into errors.
5
+ import { leadingText } from "./text-boundary.js";
5
6
  const AUTH_ORIGIN = "https://auth.openai.com";
6
7
  const TIMEOUT_MS = 15_000;
7
8
  const MAX_BODY_CHARS = 64_000;
@@ -96,7 +97,7 @@ function errorDetail(value, secrets) {
96
97
  let safe = detail.replace(/[\r\n]+/g, " ");
97
98
  for (const secret of secrets)
98
99
  safe = safe.replaceAll(secret, "[credential redacted]");
99
- return ` · ${safe.slice(0, 300)}`;
100
+ return ` · ${leadingText(safe, 300)}`;
100
101
  }
101
102
  function bodySecrets(body) {
102
103
  const values = body.contentType === "application/x-www-form-urlencoded"
@@ -2,6 +2,7 @@
2
2
  import { timingSafeEqual } from "node:crypto";
3
3
  import { readFileSync } from "node:fs";
4
4
  import { createServer } from "node:http";
5
+ import { leadingText } from "./text-boundary.js";
5
6
  const CALLBACK_PORTS = [1455, 1457];
6
7
  export const OPENAI_CALLBACK_PATH = "/auth/callback";
7
8
  export async function openAICallback(state) {
@@ -42,7 +43,7 @@ export async function openAICallback(state) {
42
43
  const authError = incoming.searchParams.get("error_description") ?? incoming.searchParams.get("error");
43
44
  const authorizationCode = incoming.searchParams.get("code");
44
45
  if (authError !== null) {
45
- rejectCode(new Error(`ChatGPT sign-in was rejected · ${authError.slice(0, 300)}`));
46
+ rejectCode(new Error(`ChatGPT sign-in was rejected · ${leadingText(authError, 300)}`));
46
47
  }
47
48
  else if (authorizationCode === null || authorizationCode === "") {
48
49
  rejectCode(new Error("ChatGPT sign-in returned no authorization code"));
@@ -80,7 +80,7 @@ export const anthropic = {
80
80
  effort: requireSupportedEffort(req.model, req.effort, efforts),
81
81
  };
82
82
  }
83
- const events = await postSse(ENDPOINT, headers(key), body, req.signal, req.onStatus);
83
+ const events = await postSse(ENDPOINT, headers(key), body, req.maxTokens, req.signal, req.onStatus);
84
84
  const data = await assembleAnthropic(events, req.onStream);
85
85
  // A refusal or a truncation never arrives as streamed text, so it has to
86
86
  // be announced separately or the user watches the turn end in silence.
@@ -1,7 +1,9 @@
1
1
  // The entire HTTP layer: one bounded request, then either a JSON body or an
2
2
  // event stream. Only idempotent reads retry. Once a POST starts or response
3
3
  // bytes flow, a failure is surfaced rather than silently replayed.
4
+ import { leadingText } from "../text-boundary.js";
4
5
  import { readSseJson } from "./sse.js";
6
+ import { sseStreamCharacterLimit } from "./stream-limits.js";
5
7
  const RETRYABLE = new Set([408, 409, 429, 500, 502, 503, 504]);
6
8
  const MAX_JSON_CHARS = 5_000_000;
7
9
  const MAX_ERROR_CHARS = 2_000;
@@ -30,14 +32,15 @@ async function asJson(url, res) {
30
32
  return JSON.parse(text);
31
33
  }
32
34
  catch {
33
- throw httpError(`${url} returned non-JSON`, res.status, text.slice(0, 500));
35
+ throw httpError(`${url} returned non-JSON`, res.status, leadingText(text, 500));
34
36
  }
35
37
  }
36
- export async function postSse(url, headers, body, signal, onStatus) {
38
+ export async function postSse(url, headers, body, maxOutputTokens, signal, onStatus) {
39
+ const maximumChars = sseStreamCharacterLimit(maxOutputTokens);
37
40
  const res = await request(url, { accept: "text/event-stream", ...headers }, body, signal, onStatus);
38
41
  if (res.body === null)
39
42
  throw httpError(`${url} returned no body`, res.status);
40
- return readSseJson(withIdleTimeout(url, res.body));
43
+ return readSseJson(withIdleTimeout(url, res.body), maximumChars);
41
44
  }
42
45
  async function request(url, headers, body, signal, onStatus) {
43
46
  const maxRetries = body === undefined ? GET_RETRIES : 0;
@@ -105,13 +108,13 @@ async function boundedText(url, res, max) {
105
108
  if (done) {
106
109
  text += decoder.decode();
107
110
  return text.length > max
108
- ? { text: text.slice(0, max), truncated: true }
111
+ ? { text: leadingText(text, max), truncated: true }
109
112
  : { text, truncated: false };
110
113
  }
111
114
  text += decoder.decode(value, { stream: true });
112
115
  if (text.length > max) {
113
116
  await reader.cancel().catch(() => undefined);
114
- return { text: text.slice(0, max), truncated: true };
117
+ return { text: leadingText(text, max), truncated: true };
115
118
  }
116
119
  }
117
120
  }
@@ -88,7 +88,7 @@ export const ollama = {
88
88
  max_tokens: req.maxTokens,
89
89
  reasoning_effort: effort,
90
90
  stream: true,
91
- }, req.signal, req.onStatus);
91
+ }, req.maxTokens, req.signal, req.onStatus);
92
92
  const reply = await assembleOllama(events, req.onStream);
93
93
  const notice = stopNotice(reply);
94
94
  if (notice !== undefined)
@@ -71,7 +71,7 @@ export const openaiCodex = {
71
71
  text: { verbosity: "low" },
72
72
  include: ["reasoning.encrypted_content"],
73
73
  prompt_cache_key: SESSION_ID,
74
- }, req.signal, req.onStatus);
74
+ }, req.maxTokens, req.signal, req.onStatus);
75
75
  const data = await assembleOpenAI(events, req.onStream);
76
76
  const notice = stopNotice(data);
77
77
  if (notice !== undefined)
@@ -152,8 +152,6 @@ function modelContextWindow(entry) {
152
152
  return undefined;
153
153
  const percent = percentage(entry["effective_context_window_percent"]) ?? 95;
154
154
  const tokens = Math.floor(resolved * percent / 100);
155
- if (!validTokenCount(tokens))
156
- return undefined;
157
155
  const automatic = Math.floor(resolved * 9 / 10);
158
156
  const advertised = tokenCount(entry["auto_compact_token_limit"]);
159
157
  return Object.freeze({
@@ -86,7 +86,7 @@ export const openai = {
86
86
  store: false,
87
87
  include: ["reasoning.encrypted_content"],
88
88
  stream: true,
89
- }, req.signal, req.onStatus);
89
+ }, req.maxTokens, req.signal, req.onStatus);
90
90
  const data = await assembleOpenAI(events, req.onStream);
91
91
  const notice = stopNotice(data);
92
92
  if (notice !== undefined)
@@ -3,41 +3,30 @@
3
3
  // The format is small: `field: value` lines, a blank line ends an event. Only
4
4
  // `data` matters here — both providers put the event discriminator inside the
5
5
  // JSON payload, so the `event:` line is redundant and skipped.
6
- import { addBounded, MAX_SSE_EVENT_CHARS, MAX_SSE_STREAM_CHARS, } from "./stream-limits.js";
7
- export async function* readSseJson(body) {
6
+ import { addBounded, MAX_SSE_EVENT_CHARS, } from "./stream-limits.js";
7
+ export async function* readSseJson(body, maximumChars) {
8
8
  const reader = body.getReader();
9
9
  const decoder = new TextDecoder();
10
- let buffer = "";
10
+ const parser = new SseEventParser();
11
11
  let finished = false;
12
12
  let total = 0;
13
- const append = (text) => {
14
- total = addBounded(total, text.length, MAX_SSE_STREAM_CHARS, "SSE stream");
15
- buffer += text;
16
- };
17
13
  try {
18
14
  for (;;) {
19
15
  const { done, value } = await reader.read();
20
16
  if (done)
21
17
  break;
22
- append(decoder.decode(value, { stream: true }));
23
- for (;;) {
24
- const boundary = findBoundary(buffer);
25
- if (boundary === undefined)
26
- break;
27
- assertEventSize(boundary.start);
28
- const chunk = buffer.slice(0, boundary.start);
29
- buffer = buffer.slice(boundary.end);
30
- const payload = parseData(chunk);
31
- if (payload !== undefined)
32
- yield payload;
33
- }
34
- assertEventSize(buffer.length);
18
+ const text = decoder.decode(value, { stream: true });
19
+ total = addBounded(total, text.length, maximumChars, "SSE stream");
20
+ for (const payload of parser.push(text))
21
+ yield payload;
35
22
  }
36
23
  // A stream that ends without a trailing blank line still owes us its last
37
24
  // event.
38
- append(decoder.decode());
39
- assertEventSize(buffer.length);
40
- const payload = parseData(buffer);
25
+ const text = decoder.decode();
26
+ total = addBounded(total, text.length, maximumChars, "SSE stream");
27
+ for (const payload of parser.push(text))
28
+ yield payload;
29
+ const payload = parser.finish();
41
30
  if (payload !== undefined)
42
31
  yield payload;
43
32
  finished = true;
@@ -48,28 +37,116 @@ export async function* readSseJson(body) {
48
37
  reader.releaseLock();
49
38
  }
50
39
  }
40
+ // Keep fragments in bounded groups. A provider may split one SSE line into
41
+ // hundreds of thousands of tiny chunks; repeatedly flattening the growing line
42
+ // would make parsing quadratic even if boundary scanning itself were linear.
43
+ class TextParts {
44
+ #groups = [];
45
+ #pieces = [];
46
+ #lastCodeUnit = "";
47
+ length = 0;
48
+ append(text) {
49
+ if (text === "")
50
+ return;
51
+ this.#pieces.push(text);
52
+ this.#lastCodeUnit = text.at(-1);
53
+ this.length += text.length;
54
+ if (this.#pieces.length >= 256)
55
+ this.#flush();
56
+ }
57
+ take() {
58
+ this.#flush();
59
+ const text = this.#groups.length === 1 ? this.#groups[0] : this.#groups.join("");
60
+ this.#groups.length = 0;
61
+ this.#lastCodeUnit = "";
62
+ this.length = 0;
63
+ return text;
64
+ }
65
+ endsWithCarriageReturn() {
66
+ return this.#lastCodeUnit === "\r";
67
+ }
68
+ #flush() {
69
+ if (this.#pieces.length === 0)
70
+ return;
71
+ this.#groups.push(this.#pieces.length === 1 ? this.#pieces[0] : this.#pieces.join(""));
72
+ this.#pieces = [];
73
+ }
74
+ }
75
+ export class SseEventParser {
76
+ #line = new TextParts();
77
+ #data = [];
78
+ #eventLength = 0;
79
+ #hasLine = false;
80
+ #pendingTerminatorLength = 0;
81
+ *push(text) {
82
+ let start = 0;
83
+ for (;;) {
84
+ const newline = text.indexOf("\n", start);
85
+ if (newline === -1) {
86
+ this.#line.append(text.slice(start));
87
+ this.#assertPendingLineSize();
88
+ return;
89
+ }
90
+ this.#line.append(text.slice(start, newline));
91
+ const rawLine = this.#line.take();
92
+ const crlf = rawLine.endsWith("\r");
93
+ const line = crlf ? rawLine.slice(0, -1) : rawLine;
94
+ if (line === "") {
95
+ const payload = this.#finishEvent();
96
+ if (payload !== undefined)
97
+ yield payload;
98
+ }
99
+ else {
100
+ this.#appendLine(line, crlf ? 2 : 1);
101
+ }
102
+ start = newline + 1;
103
+ }
104
+ }
105
+ finish() {
106
+ const line = this.#line.take();
107
+ if (line !== "") {
108
+ this.#appendLine(line, 0);
109
+ }
110
+ else if (this.#hasLine) {
111
+ // A single trailing line terminator is part of an unterminated event.
112
+ assertEventSize(this.#eventLength + this.#pendingTerminatorLength);
113
+ }
114
+ return this.#finishEvent();
115
+ }
116
+ #appendLine(line, terminatorLength) {
117
+ const separatorLength = this.#hasLine ? this.#pendingTerminatorLength : 0;
118
+ assertEventSize(this.#eventLength + separatorLength + line.length);
119
+ this.#eventLength += separatorLength + line.length;
120
+ this.#hasLine = true;
121
+ this.#pendingTerminatorLength = terminatorLength;
122
+ if (line.startsWith("data:")) {
123
+ this.#data.push(line.slice("data:".length).trimStart());
124
+ }
125
+ }
126
+ #assertPendingLineSize() {
127
+ const separatorLength = this.#hasLine ? this.#pendingTerminatorLength : 0;
128
+ const remaining = MAX_SSE_EVENT_CHARS - this.#eventLength - separatorLength;
129
+ // One trailing CR may turn out to be part of a split CRLF terminator.
130
+ const splitCrlf = this.#line.length === remaining + 1 && this.#line.endsWithCarriageReturn();
131
+ if (this.#line.length > remaining && !splitCrlf) {
132
+ assertEventSize(MAX_SSE_EVENT_CHARS + 1);
133
+ }
134
+ }
135
+ #finishEvent() {
136
+ const data = this.#data.join("\n");
137
+ this.#data = [];
138
+ this.#eventLength = 0;
139
+ this.#hasLine = false;
140
+ this.#pendingTerminatorLength = 0;
141
+ return parseData(data);
142
+ }
143
+ }
51
144
  function assertEventSize(length) {
52
145
  if (length > MAX_SSE_EVENT_CHARS) {
53
146
  throw new Error(`SSE event exceeded ${MAX_SSE_EVENT_CHARS} characters`);
54
147
  }
55
148
  }
56
- // Handles both LF and CRLF framing without normalising the buffer first — a
57
- // normalising pass would have to cope with a \r\n split across two chunks.
58
- function findBoundary(buffer) {
59
- const lf = buffer.indexOf("\n\n");
60
- const crlf = buffer.indexOf("\r\n\r\n");
61
- if (lf === -1 && crlf === -1)
62
- return undefined;
63
- if (crlf !== -1 && (lf === -1 || crlf < lf))
64
- return { start: crlf, end: crlf + 4 };
65
- return { start: lf, end: lf + 2 };
66
- }
67
- function parseData(chunk) {
68
- const data = chunk
69
- .split(/\r?\n/)
70
- .filter((line) => line.startsWith("data:"))
71
- .map((line) => line.slice("data:".length).trimStart())
72
- .join("\n");
149
+ function parseData(data) {
73
150
  if (data === "" || data === "[DONE]")
74
151
  return undefined;
75
152
  try {
@@ -1,8 +1,20 @@
1
1
  // Response streams are remote input. Bound the pieces that otherwise grow
2
- // independently of the request's output-token setting.
2
+ // independently, while letting a larger requested output carry its necessarily
3
+ // larger framing, terminal envelope, and opaque reasoning payloads.
3
4
  export const MAX_SSE_EVENT_CHARS = 1_000_000;
4
- export const MAX_SSE_STREAM_CHARS = 4_000_000;
5
+ export const MAX_SSE_STREAM_CHARS = 256_000_000;
5
6
  export const MAX_TOOL_ARGUMENT_CHARS = 1_000_000;
7
+ const MIN_SSE_STREAM_CHARS = 4_000_000;
8
+ const SSE_CHARS_PER_OUTPUT_TOKEN = 512;
9
+ export function sseStreamCharacterLimit(maxOutputTokens) {
10
+ if (!Number.isSafeInteger(maxOutputTokens) || maxOutputTokens <= 0) {
11
+ throw new Error("max output tokens must be a positive safe integer");
12
+ }
13
+ const scaled = maxOutputTokens > Math.floor(MAX_SSE_STREAM_CHARS / SSE_CHARS_PER_OUTPUT_TOKEN)
14
+ ? MAX_SSE_STREAM_CHARS
15
+ : maxOutputTokens * SSE_CHARS_PER_OUTPUT_TOKEN;
16
+ return Math.max(MIN_SSE_STREAM_CHARS, scaled);
17
+ }
6
18
  export function addBounded(total, added, maximum, label) {
7
19
  if (added > maximum - total) {
8
20
  throw new Error(`${label} exceeded ${maximum} characters`);
@@ -8,6 +8,7 @@ import { chmod, lstat, mkdir, open, opendir, readFile, readdir, realpath, rename
8
8
  import * as path from "node:path";
9
9
  import { atomicWrite } from "../atomic.js";
10
10
  import { CONVERSATION_LIMITS, ConversationTree } from "../conversation.js";
11
+ import { leadingText } from "../text-boundary.js";
11
12
  import { userDataPath } from "../user-data.js";
12
13
  import { decodeHead, decodeMeta, decodeNode, encodeHead, encodeMeta, encodeNode, SESSION_FILE_LIMITS, SESSION_SCHEMA, } from "./codec.js";
13
14
  import { leaseOwner, leaseToken, pidIsAlive, removeLease, sessionLease, } from "./lease.js";
@@ -367,7 +368,7 @@ function firstUserText(conversation) {
367
368
  const text = message.content.find((block) => block.kind === "text")?.text
368
369
  .replace(/\s+/gu, " ").trim();
369
370
  if (text !== undefined && text !== "")
370
- return text.slice(0, 160);
371
+ return leadingText(text, 160);
371
372
  }
372
373
  return "Untitled session";
373
374
  }
@@ -0,0 +1,47 @@
1
+ // Shared grapheme boundaries for every projection of user-visible text.
2
+ const SEGMENTER = new Intl.Segmenter(undefined, { granularity: "grapheme" });
3
+ export function segmentGraphemes(text) {
4
+ return SEGMENTER.segment(text);
5
+ }
6
+ export function graphemes(text) {
7
+ const out = [];
8
+ for (const { segment } of segmentGraphemes(text))
9
+ out.push(segment);
10
+ return out;
11
+ }
12
+ /** Largest complete grapheme boundary no greater than a UTF-16 offset. */
13
+ export function graphemeFloor(text, offset) {
14
+ const target = Math.max(0, Math.min(text.length, offset));
15
+ if (target === 0 || target === text.length)
16
+ return target;
17
+ const containing = segmentGraphemes(text).containing(target);
18
+ if (containing === undefined || containing.index === target)
19
+ return target;
20
+ return containing.index;
21
+ }
22
+ /** Smallest complete grapheme boundary no less than a UTF-16 offset. */
23
+ export function graphemeCeiling(text, offset) {
24
+ const target = Math.max(0, Math.min(text.length, offset));
25
+ if (target === 0 || target === text.length)
26
+ return target;
27
+ const containing = segmentGraphemes(text).containing(target);
28
+ if (containing === undefined || containing.index === target)
29
+ return target;
30
+ return containing.index + containing.segment.length;
31
+ }
32
+ /** Keep a bounded prefix without returning part of a user-perceived character. */
33
+ export function leadingText(text, maxCodeUnits) {
34
+ if (maxCodeUnits <= 0)
35
+ return "";
36
+ if (text.length <= maxCodeUnits)
37
+ return text;
38
+ return text.slice(0, graphemeFloor(text, maxCodeUnits));
39
+ }
40
+ /** Keep a bounded suffix without returning part of a user-perceived character. */
41
+ export function trailingText(text, maxCodeUnits) {
42
+ if (maxCodeUnits <= 0)
43
+ return "";
44
+ if (text.length <= maxCodeUnits)
45
+ return text;
46
+ return text.slice(graphemeCeiling(text, text.length - maxCodeUnits));
47
+ }
package/dist/timeline.js CHANGED
@@ -3,6 +3,7 @@
3
3
  // Selecting a node changes only the in-memory path. The next real user turn
4
4
  // is what persists a branch, so opening or cancelling this control plane can
5
5
  // never create empty history.
6
+ import { leadingText } from "./text-boundary.js";
6
7
  import { usageFromHistory } from "./usage.js";
7
8
  import { heading } from "./tui/picker.js";
8
9
  export function timelinePicker(conversation, palette) {
@@ -83,7 +84,7 @@ function preview(node) {
83
84
  const text = message.content.find((block) => block.kind === "text")?.text
84
85
  .replace(/\s+/gu, " ").trim();
85
86
  if (text !== undefined && text !== "")
86
- return text.slice(0, 160);
87
+ return leadingText(text, 160);
87
88
  }
88
89
  return "Untitled turn";
89
90
  }