jeo-code 0.6.18 → 0.6.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
6
6
 
7
7
  The README mirrors the latest 5 entries — regenerate with `bun run changelog:sync`.
8
8
 
9
+ ## [0.6.19] - 2026-06-18
10
+ _Post-turn hooks run once per batch (not per edit), local hook reads are mtime-cached, tool-result formatting is parallelized, and wrapped colored text keeps its tint._
11
+
12
+ ### Changed
13
+ - **Post-turn hooks execute ONCE per multi-call batch instead of once per result.** A project-wide checker hook (`tsc --noEmit`/lint/test) whose `match.tool` matched every edit in a batch previously re-ran N times sequentially — the dominant in-loop latency multiplier on multi-edit turns. `runPostTurnHooksForBatch` now groups the batch's calls, invokes each matching hook a single time, and runs distinct hooks concurrently. A hook matching several calls receives a back-compatible payload (`{event,tool,args,success,output}` plus a `calls[]` array of every matched call) so a payload-aware per-file hook can still iterate the changed files in one invocation; a single match keeps the exact legacy shape. The single-call `runPostTurnHooks` is retained as a thin wrapper for direct callers/tests.
14
+ - **Tool-result bodies are formatted in parallel.** The per-result loop that serialized body formatting (and any oversized-body spill to a disk artifact) is replaced by a `Promise.all` over the batch, so independent formatting/disk writes overlap.
15
+
16
+ ### Performance
17
+ - **Local `.jeo/hooks.json` is mtime/size-cached.** The per-project hook override was re-read and re-parsed (`fs.readFile` + `JSON.parse`) on every `loadHooks` call — once per pre-tool check and once per post-turn batch. It is now cached keyed by absolute path → mtime/size (bounded LRU, cap 32) and only re-read when the file actually changes, so any external write is still picked up immediately without a stale serve.
18
+
19
+ ### Fixed
20
+ - **Wrapped colored text keeps its color on every continuation row.** `wrapTextWithAnsi` is now SGR-stateful: a color opened before a wrap point is re-applied at the start of each continuation line and closed at its end, so a wrapped colored span stays tinted on every row (the reported "color breaks when the line wraps") instead of losing its tint after the first line — and never bleeds into the padding or box border. Plain uncolored text is left byte-for-byte unchanged.
21
+
22
+
9
23
  ## [0.6.18] - 2026-06-17
10
24
  _Memory data-flow diagram and a README "Memory flow" section documenting the actual runtime behavior._
11
25
 
package/README.ja.md CHANGED
@@ -200,11 +200,11 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
200
200
  ## 変更履歴 (Changelog)
201
201
 
202
202
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
203
+ - **[0.6.19]** (2026-06-18) — Post-turn hooks run once per batch (not per edit), local hook reads are mtime-cached, tool-result formatting is parallelized, and wrapped colored text keeps its tint.
203
204
  - **[0.6.18]** (2026-06-17) — Memory data-flow diagram and a README "Memory flow" section documenting the actual runtime behavior.
204
205
  - **[0.6.17]** (2026-06-17) — Legacy MEMORY.md migrates losslessly into the OKF concept bundle, with a one-shot command and a rollback toggle.
205
206
  - **[0.6.16]** (2026-06-17) — OKF memory grows a concept cross-link graph: 1-hop search expansion, bundle lint, graphify-optional.
206
207
  - **[0.6.15]** (2026-06-17) — Query-aware OKF memory injection with budget-priority selection, and a truthful end-of-turn Todos receipt.
207
- - **[0.6.14]** (2026-06-16) — Memory distillation survives malformed model output, and stream-idle stalls retry instead of failing the turn.
208
208
 
209
209
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
210
210
  <!-- CHANGELOG:END -->
package/README.ko.md CHANGED
@@ -200,11 +200,11 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
200
200
  ## 변경 이력 (Changelog)
201
201
 
202
202
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
203
+ - **[0.6.19]** (2026-06-18) — Post-turn hooks run once per batch (not per edit), local hook reads are mtime-cached, tool-result formatting is parallelized, and wrapped colored text keeps its tint.
203
204
  - **[0.6.18]** (2026-06-17) — Memory data-flow diagram and a README "Memory flow" section documenting the actual runtime behavior.
204
205
  - **[0.6.17]** (2026-06-17) — Legacy MEMORY.md migrates losslessly into the OKF concept bundle, with a one-shot command and a rollback toggle.
205
206
  - **[0.6.16]** (2026-06-17) — OKF memory grows a concept cross-link graph: 1-hop search expansion, bundle lint, graphify-optional.
206
207
  - **[0.6.15]** (2026-06-17) — Query-aware OKF memory injection with budget-priority selection, and a truthful end-of-turn Todos receipt.
207
- - **[0.6.14]** (2026-06-16) — Memory distillation survives malformed model output, and stream-idle stalls retry instead of failing the turn.
208
208
 
209
209
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
210
210
  <!-- CHANGELOG:END -->
package/README.md CHANGED
@@ -200,11 +200,11 @@ Required npm token permissions (repository secret `NPM_TOKEN`):
200
200
  ## Changelog
201
201
 
202
202
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
203
+ - **[0.6.19]** (2026-06-18) — Post-turn hooks run once per batch (not per edit), local hook reads are mtime-cached, tool-result formatting is parallelized, and wrapped colored text keeps its tint.
203
204
  - **[0.6.18]** (2026-06-17) — Memory data-flow diagram and a README "Memory flow" section documenting the actual runtime behavior.
204
205
  - **[0.6.17]** (2026-06-17) — Legacy MEMORY.md migrates losslessly into the OKF concept bundle, with a one-shot command and a rollback toggle.
205
206
  - **[0.6.16]** (2026-06-17) — OKF memory grows a concept cross-link graph: 1-hop search expansion, bundle lint, graphify-optional.
206
207
  - **[0.6.15]** (2026-06-17) — Query-aware OKF memory injection with budget-priority selection, and a truthful end-of-turn Todos receipt.
207
- - **[0.6.14]** (2026-06-16) — Memory distillation survives malformed model output, and stream-idle stalls retry instead of failing the turn.
208
208
 
209
209
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
210
210
  <!-- CHANGELOG:END -->
package/README.zh.md CHANGED
@@ -200,11 +200,11 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
200
200
  ## 更新日志 (Changelog)
201
201
 
202
202
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
203
+ - **[0.6.19]** (2026-06-18) — Post-turn hooks run once per batch (not per edit), local hook reads are mtime-cached, tool-result formatting is parallelized, and wrapped colored text keeps its tint.
203
204
  - **[0.6.18]** (2026-06-17) — Memory data-flow diagram and a README "Memory flow" section documenting the actual runtime behavior.
204
205
  - **[0.6.17]** (2026-06-17) — Legacy MEMORY.md migrates losslessly into the OKF concept bundle, with a one-shot command and a rollback toggle.
205
206
  - **[0.6.16]** (2026-06-17) — OKF memory grows a concept cross-link graph: 1-hop search expansion, bundle lint, graphify-optional.
206
207
  - **[0.6.15]** (2026-06-17) — Query-aware OKF memory injection with budget-priority selection, and a truthful end-of-turn Todos receipt.
207
- - **[0.6.14]** (2026-06-16) — Memory distillation survives malformed model output, and stream-idle stalls retry instead of failing the turn.
208
208
 
209
209
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
210
210
  <!-- CHANGELOG:END -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jeo-code",
3
- "version": "0.6.18",
3
+ "version": "0.6.19",
4
4
 
5
5
  "description": "Clean, highly optimized AI coding agent using spec-first loop",
6
6
  "type": "module",
@@ -16,7 +16,7 @@ import { readTool, writeTool, editTool, bashTool, findTool, searchTool, lsTool,
16
16
  import { webSearchTool, setWebSearchActiveModel } from "./web-search";
17
17
  import { friendlyProviderError, isContextOverflowError, isRefusalError } from "../util/provider-error";
18
18
  import { isRateLimitError } from "../util/retry";
19
- import { runPreToolHooks, runPostTurnHooks } from "./hooks";
19
+ import { runPreToolHooks, runPostTurnHooksForBatch } from "./hooks";
20
20
  import { truncateToolOutput, formatToolResultBody } from "./tool-output";
21
21
  export { TOOL_OUTPUT_MAX, READ_OUTPUT_MAX, TOOL_SPILL_THRESHOLD, MAX_TOOL_ARTIFACTS, truncateToolOutput, spillToolResult } from "./tool-output";
22
22
  import { StepBudget, dynamicStepBudgetConfig, resolveStepBudgetConfig, hashSignature, type StepBudgetConfig } from "./step-budget";
@@ -903,46 +903,53 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
903
903
  }
904
904
 
905
905
  const processAndPushResults = async (indices: number[]) => {
906
- const resultBlocks: string[] = [];
907
- // Per-batch dedup of post-turn hook diagnostics: a whole-project `tsc` hook
908
- // matching every edit in a batch yields identical output N times — show it
909
- // once, cross-reference the rest (cycle 13).
910
- const seenHookFeedback = new Set<string>();
906
+ // Surface completion to the TUI ledger in call order.
911
907
  for (const idx of indices) {
912
- const call = toolCalls[idx];
913
- const res = results[idx];
914
-
915
- ev.onToolResult?.(call.tool, res.success, res.output);
916
-
917
- const resultBody = await formatToolResultBody(call.tool, res.output, cwd);
918
-
919
- const { diags: hookDiags, ran: hooksRan } = await runPostTurnHooks(
920
- cwd,
921
- call.tool,
922
- call.arguments ?? {},
923
- res.success,
924
- res.output,
925
- opts.signal,
926
- ev.onNotice
927
- );
928
- // F1: a red hook becomes a pending failure the done guard enforces; a
929
- // later hook run that completes CLEAN (ran > 0, zero diags) clears it.
930
- if (hookDiags.length > 0) pendingHookFailure = hookDiags[hookDiags.length - 1].run;
931
- else if (hooksRan > 0) pendingHookFailure = null;
908
+ ev.onToolResult?.(toolCalls[idx].tool, results[idx].success, results[idx].output);
909
+ }
910
+ // Format every result body in PARALLEL — independent work, and an oversized
911
+ // body may spill to a disk artifact; the previous per-result loop serialized
912
+ // both the formatting and the disk writes.
913
+ const bodies = await Promise.all(
914
+ indices.map(idx => formatToolResultBody(toolCalls[idx].tool, results[idx].output, cwd)),
915
+ );
916
+ // Run post-turn hooks ONCE for the whole batch instead of once per result: a
917
+ // project-wide `tsc`/lint/test hook matching every edit in the batch no longer
918
+ // re-executes N times sequentially (the dominant in-loop latency multiplier).
919
+ const { diags: hookDiags, ran: hooksRan } = await runPostTurnHooksForBatch(
920
+ cwd,
921
+ indices.map(idx => ({
922
+ tool: toolCalls[idx].tool,
923
+ args: toolCalls[idx].arguments ?? {},
924
+ success: results[idx].success,
925
+ output: results[idx].output,
926
+ })),
927
+ opts.signal,
928
+ ev.onNotice,
929
+ );
930
+ // F1: a red hook becomes a pending failure the done guard enforces; a later
931
+ // batch whose hooks complete CLEAN (ran > 0, zero diags) clears it.
932
+ if (hookDiags.length > 0) pendingHookFailure = hookDiags[hookDiags.length - 1].run;
933
+ else if (hooksRan > 0) pendingHookFailure = null;
932
934
 
933
- // Append non-zero-exit hook diagnostics to THIS tool's result block so the
934
- // model can self-correct. The tool's own ok/fail is unchanged (guard).
935
- let resultBlock = `Tool [${call.tool}] result (${res.success ? "ok" : "fail"}):\n${resultBody}`;
935
+ const resultBlocks: string[] = indices.map((idx, i) =>
936
+ `Tool [${toolCalls[idx].tool}] result (${results[idx].success ? "ok" : "fail"}):\n${bodies[i]}`,
937
+ );
938
+ // Append the batch's hook diagnostics once so the model can self-correct. Two
939
+ // DISTINCT hooks with identical output collapse to one full block + a cross-ref.
940
+ if (hookDiags.length > 0) {
941
+ const seenHookFeedback = new Set<string>();
942
+ const diagLines: string[] = [];
936
943
  for (const d of hookDiags) {
937
944
  const key = `${d.run}\u0000${d.output}`;
938
945
  if (seenHookFeedback.has(key)) {
939
- resultBlock += `\n[post-turn hook "${d.run}" — exit ${d.exitCode}: same diagnostics as above]`;
946
+ diagLines.push(`[post-turn hook "${d.run}" — exit ${d.exitCode}: same diagnostics as above]`);
940
947
  } else {
941
948
  seenHookFeedback.add(key);
942
- resultBlock += `\n[post-turn hook "${d.run}" — exit ${d.exitCode}]:\n${truncateToolOutput(d.output)}`;
949
+ diagLines.push(`[post-turn hook "${d.run}" — exit ${d.exitCode}]:\n${truncateToolOutput(d.output)}`);
943
950
  }
944
951
  }
945
- resultBlocks.push(resultBlock);
952
+ resultBlocks.push(diagLines.join("\n"));
946
953
  }
947
954
 
948
955
  history.push({ role: "assistant", content: responseText });
@@ -22,27 +22,56 @@ export interface PostTurnHookDiag {
22
22
  output: string;
23
23
  }
24
24
 
25
+ // Local `.jeo/hooks.json` read cache, keyed by absolute path → mtime/size. The
26
+ // global config is already mtime-cached in state.ts, but the per-project local
27
+ // override was re-read (fs.readFile + JSON.parse) on every loadHooks call — once
28
+ // per pre-tool hook check and once per post-turn batch. Cache the parsed outcome
29
+ // and re-read only when the file's mtime/size changes (any external write bumps
30
+ // both, so a stale entry is never served).
31
+ type LocalHooks =
32
+ | { kind: "disabled" }
33
+ | { kind: "hooks"; hooks: NonNullable<HookConfig["hooks"]> }
34
+ | { kind: "fallback" };
35
+ const localHooksCache = new Map<string, { mtimeMs: number; size: number; result: LocalHooks }>();
36
+ const LOCAL_HOOKS_CACHE_CAP = 32;
37
+
38
+ async function readLocalHooks(localPath: string): Promise<LocalHooks> {
39
+ let st: { mtimeMs: number; size: number };
40
+ try {
41
+ st = await fs.stat(localPath);
42
+ } catch {
43
+ localHooksCache.delete(localPath);
44
+ return { kind: "fallback" };
45
+ }
46
+ const hit = localHooksCache.get(localPath);
47
+ if (hit && hit.mtimeMs === st.mtimeMs && hit.size === st.size) return hit.result;
48
+ let result: LocalHooks = { kind: "fallback" };
49
+ try {
50
+ const parsed = JSON.parse(await fs.readFile(localPath, "utf-8"));
51
+ if (parsed && typeof parsed === "object") {
52
+ if (parsed.enabled === false) result = { kind: "disabled" };
53
+ else if (Array.isArray(parsed.hooks)) result = { kind: "hooks", hooks: parsed.hooks };
54
+ }
55
+ } catch {
56
+ // Missing/invalid local file → fall back to the global config hooks.
57
+ }
58
+ if (localHooksCache.size >= LOCAL_HOOKS_CACHE_CAP && !localHooksCache.has(localPath)) {
59
+ const oldest = localHooksCache.keys().next().value;
60
+ if (oldest !== undefined) localHooksCache.delete(oldest);
61
+ }
62
+ localHooksCache.set(localPath, { mtimeMs: st.mtimeMs, size: st.size, result });
63
+ return result;
64
+ }
65
+
25
66
  export async function loadHooks(cwd: string): Promise<NonNullable<HookConfig["hooks"]>> {
26
67
  const config = await readGlobalConfig();
27
68
  if (!config.hooks?.enabled) {
28
69
  return [];
29
70
  }
30
71
 
31
- const localPath = path.join(cwd, ".jeo", "hooks.json");
32
- try {
33
- const content = await fs.readFile(localPath, "utf-8");
34
- const parsed = JSON.parse(content);
35
- if (parsed && typeof parsed === "object") {
36
- if (parsed.enabled === false) {
37
- return [];
38
- }
39
- if (Array.isArray(parsed.hooks)) {
40
- return parsed.hooks;
41
- }
42
- }
43
- } catch (e) {
44
- // If local file is missing or invalid, fall back to global
45
- }
72
+ const local = await readLocalHooks(path.join(cwd, ".jeo", "hooks.json"));
73
+ if (local.kind === "disabled") return [];
74
+ if (local.kind === "hooks") return local.hooks;
46
75
 
47
76
  return config.hooks?.hooks || [];
48
77
  }
@@ -180,12 +209,32 @@ export async function runPreToolHooks(
180
209
  return { vetoed: false };
181
210
  }
182
211
 
183
- export async function runPostTurnHooks(
212
+ /** One executed tool call fed to the post-turn hooks of a batch. */
213
+ export interface PostTurnCall {
214
+ tool: string;
215
+ args: Record<string, any>;
216
+ success: boolean;
217
+ output: string;
218
+ }
219
+
220
+ function outputPreviewOf(output: string): string {
221
+ return output.length > 10000 ? output.slice(0, 10000) + "\n... (truncated)" : output;
222
+ }
223
+
224
+ /**
225
+ * Run post-turn hooks for a whole batch of executed tool calls, invoking each
226
+ * matching hook EXACTLY ONCE — not once per result. A project-wide checker
227
+ * (`tsc --noEmit`/lint/test) that matches every edit in a batch previously ran N
228
+ * times sequentially; now it runs a single time. Distinct hooks run concurrently.
229
+ *
230
+ * Payload back-compat: a hook that matches a single call gets the legacy
231
+ * `{event,tool,args,success,output}` shape; a hook matching several gets the same
232
+ * fields plus a `calls[]` array (every matched call) so a payload-aware per-file
233
+ * hook can still iterate the changed files in one invocation.
234
+ */
235
+ export async function runPostTurnHooksForBatch(
184
236
  cwd: string,
185
- tool: string,
186
- args: Record<string, any>,
187
- success: boolean,
188
- output: string,
237
+ calls: readonly PostTurnCall[],
189
238
  signal?: AbortSignal,
190
239
  onNotice?: (msg: string) => void
191
240
  ): Promise<{ diags: PostTurnHookDiag[]; ran: number }> {
@@ -195,23 +244,29 @@ export async function runPostTurnHooks(
195
244
  let ran = 0;
196
245
  try {
197
246
  const hooks = await loadHooks(cwd);
198
- const postTurnHooks = hooks.filter(
199
- h => h.event === "post-turn" && hookMatchesTool(h.match?.tool, tool)
200
- );
201
-
202
- const outputPreview = output.length > 10000 ? output.slice(0, 10000) + "\n... (truncated)" : output;
203
-
204
- for (const hook of postTurnHooks) {
205
- const payload = {
206
- event: "post-turn",
207
- tool,
208
- args,
209
- success,
210
- output: outputPreview,
211
- };
247
+ const jobs = hooks
248
+ .filter(h => h.event === "post-turn")
249
+ .map(hook => ({ hook, matched: calls.filter(c => hookMatchesTool(hook.match?.tool, c.tool)) }))
250
+ .filter(j => j.matched.length > 0);
212
251
 
252
+ // Distinct hooks are independent commands → run them concurrently. Each hook
253
+ // itself runs once for the whole batch (the redundancy this fix removes).
254
+ const settled = await Promise.all(jobs.map(async ({ hook, matched }) => {
255
+ const payload = matched.length === 1
256
+ ? { event: "post-turn", tool: matched[0].tool, args: matched[0].args, success: matched[0].success, output: outputPreviewOf(matched[0].output) }
257
+ : {
258
+ event: "post-turn",
259
+ tool: matched.map(c => c.tool).join(","),
260
+ calls: matched.map(c => ({ tool: c.tool, args: c.args, success: c.success })),
261
+ success: matched.every(c => c.success),
262
+ output: outputPreviewOf(matched.map(c => c.output).join("\n")),
263
+ };
213
264
  const timeoutMs = hook.timeoutMs || 30000;
214
265
  const result = await runHookCommand(hook.run, payload, cwd, timeoutMs, signal);
266
+ return { hook, result };
267
+ }));
268
+
269
+ for (const { hook, result } of settled) {
215
270
  if (result.timedOut) {
216
271
  onNotice?.(`Post-turn hook "${hook.run}" timed out (advisory).`);
217
272
  } else if (result.aborted) {
@@ -235,6 +290,20 @@ export async function runPostTurnHooks(
235
290
  return { diags, ran };
236
291
  }
237
292
 
293
+ /** Single-call convenience wrapper (kept for direct callers/tests). Delegates to
294
+ * the batch runner with one call, preserving the legacy payload shape. */
295
+ export async function runPostTurnHooks(
296
+ cwd: string,
297
+ tool: string,
298
+ args: Record<string, any>,
299
+ success: boolean,
300
+ output: string,
301
+ signal?: AbortSignal,
302
+ onNotice?: (msg: string) => void
303
+ ): Promise<{ diags: PostTurnHookDiag[]; ran: number }> {
304
+ return runPostTurnHooksForBatch(cwd, [{ tool, args, success, output }], signal, onNotice);
305
+ }
306
+
238
307
  export async function runPostImplementationHooks(
239
308
  cwd: string,
240
309
  request: string,
@@ -185,13 +185,32 @@ export function sanitizeForFrame(s: string): string {
185
185
  return out;
186
186
  }
187
187
 
188
+ /** Active SGR state after applying every SGR escape in `segment` to `prior`.
189
+ * Pragmatic model (matches wrap-ansi): a full reset (`\x1b[0m` / `\x1b[m`) clears
190
+ * the open set; any other SGR is appended. Good enough for the fg/bg/bold coloring
191
+ * a TUI box actually uses; it does not model selective resets (e.g. `\x1b[22m`). */
192
+ function sgrStateAfter(prior: string, segment: string): string {
193
+ if (!segment.includes("\x1b")) return prior;
194
+ let state = prior;
195
+ const re = /\x1b\[[0-9;]*m/g;
196
+ let m: RegExpExecArray | null;
197
+ while ((m = re.exec(segment))) {
198
+ state = m[0] === "\x1b[0m" || m[0] === "\x1b[m" ? "" : state + m[0];
199
+ }
200
+ return state;
201
+ }
202
+
188
203
  /**
189
204
  * Hard-wrap text to `cols` display columns, breaking long words and preserving
190
- * existing newlines. SGR-aware (escapes don't consume width). Returns the wrapped
191
- * lines. Used by markdown/table rendering where alignment must be column-correct.
205
+ * existing newlines. SGR-aware (escapes don't consume width) AND SGR-stateful:
206
+ * a color opened before a wrap point is RE-APPLIED at the start of each continuation
207
+ * line and CLOSED at its end, so a wrapped colored span stays colored on every row
208
+ * instead of losing its tint after the first line (and never bleeds into the padding
209
+ * or box border). Returns the wrapped lines.
192
210
  */
193
211
  export function wrapTextWithAnsi(text: string, cols: number): string[] {
194
212
  const width = Math.max(1, cols);
213
+ const RESET = "\x1b[0m";
195
214
  const out: string[] = [];
196
215
  for (const rawLine of text.split("\n")) {
197
216
  if (visibleWidth(rawLine) <= width) {
@@ -199,16 +218,31 @@ export function wrapTextWithAnsi(text: string, cols: number): string[] {
199
218
  continue;
200
219
  }
201
220
  let rest = rawLine;
221
+ let active = ""; // SGR open at the wrap boundary, carried to the next line
202
222
  while (visibleWidth(rest) > width) {
203
223
  const head = truncateToWidth(rest, width);
204
- // Advance past exactly the consumed substring (head may carry a trailing reset).
205
- const consumed = head.endsWith("\x1b[0m") && !rest.endsWith("\x1b[0m")
206
- ? head.slice(0, -"\x1b[0m".length)
207
- : head;
208
- out.push(head);
224
+ // Advance past exactly the consumed substring. truncateToWidth may append a
225
+ // SYNTHETIC trailing reset (frame safety) that is NOT in `rest` — including it
226
+ // would over-advance and drop real chars. `rest.startsWith(head)` means the
227
+ // reset is genuinely part of the source; otherwise strip the synthetic one.
228
+ const consumed = rest.startsWith(head)
229
+ ? head
230
+ : head.endsWith(RESET) && rest.startsWith(head.slice(0, -RESET.length))
231
+ ? head.slice(0, -RESET.length)
232
+ : head;
233
+ let line = active + head;
234
+ const next = sgrStateAfter(active, consumed);
235
+ // Close any color still open at the line end so it cannot tint the padding/border.
236
+ if (next && !line.endsWith(RESET)) line += RESET;
237
+ out.push(line);
238
+ active = next;
209
239
  rest = rest.slice(consumed.length);
210
240
  }
211
- if (rest.length > 0) out.push(rest);
241
+ if (rest.length > 0) {
242
+ let line = active + rest;
243
+ if (active && !line.endsWith(RESET)) line += RESET;
244
+ out.push(line);
245
+ }
212
246
  }
213
247
  return out;
214
248
  }