agent-sh 0.15.2 → 0.15.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.
@@ -967,7 +967,7 @@ export class AgentLoop {
967
967
  // Execute via handler — extensions can advise to add safe-mode,
968
968
  // logging, metrics, custom permission policies, etc.
969
969
  const defaultOnChunk = (chunk) => {
970
- this.bus.emit("agent:tool-output-chunk", { chunk });
970
+ this.bus.emit("agent:tool-output-chunk", { chunk, toolCallId: tc.id });
971
971
  };
972
972
  const result = await this.handlers.call("tool:execute", { name: tc.name, id: tc.id, args, tool, onChunk: defaultOnChunk,
973
973
  batchIndex, batchTotal: batchTotal > 1 ? batchTotal : undefined,
@@ -135,6 +135,7 @@ declare module "../core/event-bus.js" {
135
135
  };
136
136
  "agent:tool-output-chunk": {
137
137
  chunk: string;
138
+ toolCallId?: string;
138
139
  };
139
140
  "tool:interactive-start": Record<string, never>;
140
141
  "tool:interactive-end": Record<string, never>;
@@ -89,11 +89,15 @@ export default function agentBackend(ctx) {
89
89
  const providerContribs = new Map();
90
90
  // Settings overlay — fields here win over contributing extensions' payloads.
91
91
  const settingsProviders = new Map();
92
- for (const name of getProviderNames()) {
93
- const p = resolveProvider(name);
94
- if (p)
95
- settingsProviders.set(name, p);
96
- }
92
+ const refreshSettingsProviders = () => {
93
+ settingsProviders.clear();
94
+ for (const name of getProviderNames()) {
95
+ const p = resolveProvider(name);
96
+ if (p)
97
+ settingsProviders.set(name, p);
98
+ }
99
+ };
100
+ refreshSettingsProviders();
97
101
  const providerHooks = new Map();
98
102
  // Bakes model id so ModelEndpoint.buildReasoningParams keeps its (level) signature.
99
103
  const bindReasoning = (shapeId, model) => {
@@ -341,6 +345,7 @@ export default function agentBackend(ctx) {
341
345
  let agentLoop = null;
342
346
  let loadedExtensionNames = [];
343
347
  bus.on("agent:providers:changed", () => {
348
+ refreshSettingsProviders();
344
349
  resolvedProviders = computeResolvedProviders();
345
350
  if (!resolved)
346
351
  return;
@@ -1,7 +1,9 @@
1
1
  /**
2
2
  * OpenAI Chat Completions-compatible local/3rd-party server (Ollama, LM
3
- * Studio, vLLM, llama.cpp, …). No reasoning hook the right shape depends
4
- * on which model the server is serving; user extensions can add one.
3
+ * Studio, vLLM, llama.cpp, …). Emits the common `reasoning_effort` shape,
4
+ * with `"none"` to disable — the value most local servers honor. A server
5
+ * wanting a different disable token can override via `reasoningShape` or a
6
+ * user extension.
5
7
  */
6
8
  import type { AgentContext } from "../host-types.js";
7
9
  export default function activate(ctx: AgentContext): void;
@@ -5,6 +5,11 @@ export default function activate(ctx) {
5
5
  // Local servers often need no key; SDK still wants a non-empty string.
6
6
  const apiKey = process.env.OPENAI_API_KEY || "no-key";
7
7
  const id = "openai-compatible";
8
+ ctx.agent.providers.configure(id, {
9
+ reasoningParams: (level) => level === "off"
10
+ ? { reasoning_effort: "none" }
11
+ : { reasoning_effort: level === "xhigh" ? "high" : level },
12
+ });
8
13
  ctx.agent.providers.register({ id, apiKey, baseURL, models: [] });
9
14
  fetchModels(baseURL, apiKey).then((models) => {
10
15
  if (models.length === 0)
@@ -72,7 +72,7 @@ export async function runSubagent(opts) {
72
72
  });
73
73
  }
74
74
  const onChunk = bus && tool.showOutput !== false
75
- ? (chunk) => { bus.emit("agent:tool-output-chunk", { chunk }); }
75
+ ? (chunk) => { bus.emit("agent:tool-output-chunk", { chunk, toolCallId: tc.id }); }
76
76
  : undefined;
77
77
  const result = await tool.execute(args, onChunk);
78
78
  if (bus) {
@@ -157,7 +157,7 @@ function highlightInlineChanges(oldLine, newLine, oldPalette, newPalette, useTru
157
157
  }
158
158
  else {
159
159
  // Changed tokens: emphasis background, no syntax highlighting (emphasis stands out)
160
- result += palette.emphBg + p.bold + tokens[i].text + p.reset;
160
+ result += palette.emphBg + tokens[i].text + p.reset;
161
161
  }
162
162
  }
163
163
  return result;
@@ -246,55 +246,56 @@ function renderUnifiedHunk(hunk, layout) {
246
246
  const { noW, lineTextW, textWidth, useTrueColor, gutterLine, lang, removedPalette, addedPalette } = layout;
247
247
  const out = [];
248
248
  const pairs = findChangePairs(hunk);
249
- const renderedAsPartOfPair = new Set();
250
249
  const bgWidth = Math.max(1, textWidth - noW - 3);
251
250
  const gutter = (n) => `${p.dim}${n} │${p.reset} `;
252
251
  const change = (no, sigil, bg, fg, text) => {
253
252
  if (!gutterLine) {
254
- return `${bg}${fg}${padToWidth(`${no} ${sigil} ${preserveBg(text, bg)}`, textWidth)}${p.reset}`;
253
+ return `${bg}${padToWidth(`${fg}${no} ${sigil}${p.diffText} ${preserveBg(text, bg)}`, textWidth)}${p.reset}`;
255
254
  }
256
255
  if (useTrueColor)
257
- return gutter(no) + padToWidth(`${bg}${fg}${sigil} ${preserveBg(text, bg)}`, bgWidth) + p.reset;
256
+ return gutter(no) + padToWidth(`${bg}${fg}${sigil}${p.diffText} ${preserveBg(text, bg)}`, bgWidth) + p.reset;
258
257
  return `${gutter(no)}${fg}${sigil} ${text}${p.reset}`;
259
258
  };
259
+ const hlCache = new Map();
260
+ const highlightedPair = (pair) => {
261
+ let h = hlCache.get(pair);
262
+ if (!h) {
263
+ h = highlightInlineChanges(pair.removed.text, pair.added.text, removedPalette, addedPalette, useTrueColor, lang);
264
+ hlCache.set(pair, h);
265
+ }
266
+ return h;
267
+ };
260
268
  for (let i = 0; i < hunk.lines.length; i++) {
261
269
  const line = hunk.lines[i];
262
270
  const no = String(line.type === "removed" ? (line.oldNo ?? "") : (line.newNo ?? line.oldNo ?? "")).padStart(noW);
263
271
  if (line.type === "context") {
264
272
  const raw = truncateText(line.text, lineTextW);
265
273
  const text = lang ? highlightLine(raw, lang) : raw;
266
- // The flush gutter dims only the line number; the code stays normal/highlighted.
267
274
  out.push(!gutterLine ? `${p.dim}${no}${p.reset} ${text}` : `${gutter(no)} ${p.dim}${text}${p.reset}`);
268
275
  continue;
269
276
  }
277
+ const pair = pairs.get(i);
270
278
  if (line.type === "removed") {
271
- const pair = pairs.get(i);
272
279
  let removedText;
273
- let addedText = null;
274
- let addedNo = null;
275
- if (pair && pair.removedIdx === i) {
276
- const highlighted = highlightInlineChanges(line.text, pair.added.text, removedPalette, addedPalette, useTrueColor, lang);
277
- removedText = truncateText(highlighted.old, lineTextW);
278
- addedText = truncateText(highlighted.new, lineTextW);
279
- addedNo = String(pair.added.newNo ?? "").padStart(noW);
280
- renderedAsPartOfPair.add(pair.addedIdx);
280
+ if (pair) {
281
+ removedText = truncateText(highlightedPair(pair).old, lineTextW);
281
282
  }
282
283
  else {
283
284
  const raw = truncateText(line.text, lineTextW);
284
285
  removedText = lang ? highlightLine(raw, lang) : raw;
285
286
  }
286
287
  out.push(change(no, "-", p.errorBg, p.error, removedText));
287
- if (addedText !== null && addedNo !== null) {
288
- out.push(change(addedNo, "+", p.successBg, p.success, addedText));
289
- }
290
- continue;
291
288
  }
292
- if (line.type === "added") {
293
- if (renderedAsPartOfPair.has(i))
294
- continue;
295
- const raw = truncateText(line.text, lineTextW);
296
- const text = lang ? highlightLine(raw, lang) : raw;
297
- out.push(change(no, "+", p.successBg, p.success, text));
289
+ else {
290
+ let addedText;
291
+ if (pair) {
292
+ addedText = truncateText(highlightedPair(pair).new, lineTextW);
293
+ }
294
+ else {
295
+ const raw = truncateText(line.text, lineTextW);
296
+ addedText = lang ? highlightLine(raw, lang) : raw;
297
+ }
298
+ out.push(change(no, "+", p.successBg, p.success, addedText));
298
299
  }
299
300
  }
300
301
  return out;
@@ -362,7 +363,7 @@ function renderSplitHunk(hunk, layout) {
362
363
  }
363
364
  else if (row.left.type === "removed") {
364
365
  if (useTrueColor) {
365
- leftCol = padToWidth(`${p.errorBg}${p.error}${leftNo} ${preserveBg(leftText, p.errorBg)}`, colWidth) + p.reset;
366
+ leftCol = padToWidth(`${p.errorBg}${p.error}${leftNo} │${p.diffText} ${preserveBg(leftText, p.errorBg)}`, colWidth) + p.reset;
366
367
  }
367
368
  else {
368
369
  leftCol = padToWidth(`${p.error}${leftNo} │ ${leftText}${p.reset}`, colWidth);
@@ -376,7 +377,7 @@ function renderSplitHunk(hunk, layout) {
376
377
  }
377
378
  else if (row.right.type === "added") {
378
379
  if (useTrueColor) {
379
- rightCol = padToWidth(`${p.successBg}${p.success}${rightNo} ${preserveBg(rightText, p.successBg)}`, colWidth) + p.reset;
380
+ rightCol = padToWidth(`${p.successBg}${p.success}${rightNo} │${p.diffText} ${preserveBg(rightText, p.successBg)}`, colWidth) + p.reset;
380
381
  }
381
382
  else {
382
383
  rightCol = padToWidth(`${p.success}${rightNo} │ ${rightText}${p.reset}`, colWidth);
@@ -18,6 +18,7 @@ export interface ColorPalette {
18
18
  errorBg: string;
19
19
  successBgEmph: string;
20
20
  errorBgEmph: string;
21
+ diffText: string;
21
22
  bold: string;
22
23
  dim: string;
23
24
  italic: string;
@@ -14,10 +14,11 @@ const defaultPalette = {
14
14
  warning: "\x1b[33m", // yellow
15
15
  error: "\x1b[31m", // red
16
16
  muted: "\x1b[90m", // gray
17
- successBg: "\x1b[48;2;34;92;43m",
18
- errorBg: "\x1b[48;2;122;41;54m",
19
- successBgEmph: "\x1b[48;2;56;166;96m",
20
- errorBgEmph: "\x1b[48;2;179;89;107m",
17
+ successBg: "\x1b[48;2;26;70;34m",
18
+ errorBg: "\x1b[48;2;92;32;42m",
19
+ successBgEmph: "\x1b[48;2;38;104;56m",
20
+ errorBgEmph: "\x1b[48;2;124;50;64m",
21
+ diffText: "\x1b[97m", // bright white — readable on the red/green tints
21
22
  bold: "\x1b[1m",
22
23
  dim: "\x1b[2m",
23
24
  italic: "\x1b[3m",
@@ -318,6 +318,7 @@ export function mountAshi(
318
318
  | { t: "thinking"; ctrl: ThinkingBlock }
319
319
  | { t: "assistant"; ctrl: AssistantMessage }
320
320
  | { t: "pair"; result: ToolResultView }
321
+ | { t: "user" }
321
322
  | { t: "plain" };
322
323
  const chatEntries: ChatEntry[] = [];
323
324
  const appendEntry = (node: RenderNode, entry: ChatEntry): void => {
@@ -580,7 +581,7 @@ export function mountAshi(
580
581
  .join("")
581
582
  : "";
582
583
  if (raw.startsWith("[Compacted conversation summary]")) return;
583
- appendEntry(renderUserMessage(stripContextWrappers(raw)), { t: "plain" });
584
+ appendEntry(renderUserMessage(stripContextWrappers(raw)), { t: "user" });
584
585
  } else if (m.role === "assistant") {
585
586
  const reasoning = readReasoning(m);
586
587
  if (reasoning) {
@@ -661,7 +662,7 @@ export function mountAshi(
661
662
  bus.on("agent:query", ({ query }) => {
662
663
  app.commitScrollback?.();
663
664
  sealOpenGroup();
664
- appendEntry(renderUserMessage(query), { t: "plain" });
665
+ appendEntry(renderUserMessage(query), { t: "user" });
665
666
  activeAssistant = null;
666
667
  app.requestRender();
667
668
  });
@@ -763,7 +764,13 @@ export function mountAshi(
763
764
  app.requestRender();
764
765
  });
765
766
 
766
- bus.on("agent:tool-output-chunk", ({ chunk }) => {
767
+ bus.on("agent:tool-output-chunk", ({ chunk, toolCallId }) => {
768
+ const owner = toolCallId ? activeTools.get(toolCallId) : undefined;
769
+ if (owner?.kind === "pair") {
770
+ owner.pair.result.appendChunk(chunk);
771
+ app.requestRender();
772
+ return;
773
+ }
767
774
  for (const entry of [...activeTools.values()].reverse()) {
768
775
  if (entry.kind === "pair") {
769
776
  entry.pair.result.appendChunk(chunk);
@@ -1262,7 +1269,13 @@ export function mountAshi(
1262
1269
  }
1263
1270
  if (key.matches("ctrl+o")) {
1264
1271
  allExpanded = !allExpanded;
1265
- for (const e of chatEntries) {
1272
+ // Toggle only the latest turn; re-rendering the whole transcript is O(history).
1273
+ let start = 0;
1274
+ for (let i = chatEntries.length - 1; i >= 0; i--) {
1275
+ if (chatEntries[i]!.t === "user") { start = i; break; }
1276
+ }
1277
+ for (let i = start; i < chatEntries.length; i++) {
1278
+ const e = chatEntries[i]!;
1266
1279
  if (e.t === "group") e.group.setExpanded(allExpanded);
1267
1280
  else if (e.t === "pair") e.result.setExpanded(allExpanded);
1268
1281
  }
@@ -214,10 +214,11 @@ function renderStream(buffer: string, env: Env): string {
214
214
  const lines = display.split("\n");
215
215
  const trimmed = lines.slice(-env.previewLines).join("\n");
216
216
  const remaining = Math.max(0, lines.length - env.previewLines);
217
+ // The preview is the tail, so the hidden lines come before it — note goes above.
217
218
  const overflow = remaining > 0
218
- ? `\n${theme.fg("muted", `... (${remaining} more ${remaining === 1 ? "line" : "lines"})`)}`
219
+ ? `${theme.fg("muted", `... (${remaining} earlier ${remaining === 1 ? "line" : "lines"})`)}\n`
219
220
  : "";
220
- return `${theme.fg("toolOutput", trimmed)}${overflow}`;
221
+ return `${overflow}${theme.fg("toolOutput", trimmed)}`;
221
222
  }
222
223
 
223
224
  function lineCountHint(buffer: string): string {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sh",
3
- "version": "0.15.2",
3
+ "version": "0.15.4",
4
4
  "description": "A composable agent runtime — pair any frontend with any agent backend over one shared extension layer",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -1124,7 +1124,7 @@ export class AgentLoop implements AgentBackend {
1124
1124
  // Execute via handler — extensions can advise to add safe-mode,
1125
1125
  // logging, metrics, custom permission policies, etc.
1126
1126
  const defaultOnChunk = (chunk: string) => {
1127
- this.bus.emit("agent:tool-output-chunk", { chunk });
1127
+ this.bus.emit("agent:tool-output-chunk", { chunk, toolCallId: tc.id });
1128
1128
  };
1129
1129
  const result = await this.handlers.call(
1130
1130
  "tool:execute",
@@ -88,7 +88,7 @@ declare module "../core/event-bus.js" {
88
88
  kind?: string;
89
89
  resultDisplay?: ToolResultDisplay;
90
90
  };
91
- "agent:tool-output-chunk": { chunk: string };
91
+ "agent:tool-output-chunk": { chunk: string; toolCallId?: string };
92
92
 
93
93
  "tool:interactive-start": Record<string, never>;
94
94
  "tool:interactive-end": Record<string, never>;
@@ -116,10 +116,14 @@ export default function agentBackend(ctx: ExtensionContext): void {
116
116
 
117
117
  // Settings overlay — fields here win over contributing extensions' payloads.
118
118
  const settingsProviders = new Map<string, ResolvedProvider>();
119
- for (const name of getProviderNames()) {
120
- const p = resolveProvider(name);
121
- if (p) settingsProviders.set(name, p);
122
- }
119
+ const refreshSettingsProviders = () => {
120
+ settingsProviders.clear();
121
+ for (const name of getProviderNames()) {
122
+ const p = resolveProvider(name);
123
+ if (p) settingsProviders.set(name, p);
124
+ }
125
+ };
126
+ refreshSettingsProviders();
123
127
 
124
128
  const providerHooks = new Map<string, {
125
129
  reasoningParams?: (level: string, model?: string) => Record<string, unknown>;
@@ -368,6 +372,7 @@ export default function agentBackend(ctx: ExtensionContext): void {
368
372
  let loadedExtensionNames: string[] = [];
369
373
 
370
374
  bus.on("agent:providers:changed", () => {
375
+ refreshSettingsProviders();
371
376
  resolvedProviders = computeResolvedProviders();
372
377
  if (!resolved) return;
373
378
  bus.emit("agent:models-changed", {});
@@ -1,7 +1,9 @@
1
1
  /**
2
2
  * OpenAI Chat Completions-compatible local/3rd-party server (Ollama, LM
3
- * Studio, vLLM, llama.cpp, …). No reasoning hook the right shape depends
4
- * on which model the server is serving; user extensions can add one.
3
+ * Studio, vLLM, llama.cpp, …). Emits the common `reasoning_effort` shape,
4
+ * with `"none"` to disable — the value most local servers honor. A server
5
+ * wanting a different disable token can override via `reasoningShape` or a
6
+ * user extension.
5
7
  */
6
8
  import type { AgentContext } from "../host-types.js";
7
9
 
@@ -13,6 +15,12 @@ export default function activate(ctx: AgentContext): void {
13
15
  const apiKey = process.env.OPENAI_API_KEY || "no-key";
14
16
  const id = "openai-compatible";
15
17
 
18
+ ctx.agent.providers.configure(id, {
19
+ reasoningParams: (level) =>
20
+ level === "off"
21
+ ? { reasoning_effort: "none" }
22
+ : { reasoning_effort: level === "xhigh" ? "high" : level },
23
+ });
16
24
  ctx.agent.providers.register({ id, apiKey, baseURL, models: [] });
17
25
  fetchModels(baseURL, apiKey).then((models) => {
18
26
  if (models.length === 0) return;
@@ -157,7 +157,7 @@ export async function runSubagent(opts: SubagentOptions): Promise<string> {
157
157
  }
158
158
 
159
159
  const onChunk = bus && tool.showOutput !== false
160
- ? (chunk: string) => { bus.emit("agent:tool-output-chunk", { chunk }); }
160
+ ? (chunk: string) => { bus.emit("agent:tool-output-chunk", { chunk, toolCallId: tc.id }); }
161
161
  : undefined;
162
162
 
163
163
  const result = await tool.execute(args, onChunk);
@@ -216,7 +216,7 @@ function highlightInlineChanges(
216
216
  result += palette.rowBg + preserveBg(text, palette.rowBg);
217
217
  } else {
218
218
  // Changed tokens: emphasis background, no syntax highlighting (emphasis stands out)
219
- result += palette.emphBg + p.bold + tokens[i].text + p.reset;
219
+ result += palette.emphBg + tokens[i].text + p.reset;
220
220
  }
221
221
  }
222
222
  return result;
@@ -337,18 +337,29 @@ function renderUnifiedHunk(hunk: DiffHunk, layout: UnifiedLayout): string[] {
337
337
  const out: string[] = [];
338
338
 
339
339
  const pairs = findChangePairs(hunk);
340
- const renderedAsPartOfPair = new Set<number>();
341
340
  const bgWidth = Math.max(1, textWidth - noW - 3);
342
341
  const gutter = (n: string): string => `${p.dim}${n} │${p.reset} `;
343
342
 
344
343
  const change = (no: string, sigil: string, bg: string, fg: string, text: string): string => {
345
344
  if (!gutterLine) {
346
- return `${bg}${fg}${padToWidth(`${no} ${sigil} ${preserveBg(text, bg)}`, textWidth)}${p.reset}`;
345
+ return `${bg}${padToWidth(`${fg}${no} ${sigil}${p.diffText} ${preserveBg(text, bg)}`, textWidth)}${p.reset}`;
347
346
  }
348
- if (useTrueColor) return gutter(no) + padToWidth(`${bg}${fg}${sigil} ${preserveBg(text, bg)}`, bgWidth) + p.reset;
347
+ if (useTrueColor) return gutter(no) + padToWidth(`${bg}${fg}${sigil}${p.diffText} ${preserveBg(text, bg)}`, bgWidth) + p.reset;
349
348
  return `${gutter(no)}${fg}${sigil} ${text}${p.reset}`;
350
349
  };
351
350
 
351
+ const hlCache = new Map<ChangePair, { old: string; new: string }>();
352
+ const highlightedPair = (pair: ChangePair): { old: string; new: string } => {
353
+ let h = hlCache.get(pair);
354
+ if (!h) {
355
+ h = highlightInlineChanges(
356
+ pair.removed.text, pair.added.text, removedPalette, addedPalette, useTrueColor, lang,
357
+ );
358
+ hlCache.set(pair, h);
359
+ }
360
+ return h;
361
+ };
362
+
352
363
  for (let i = 0; i < hunk.lines.length; i++) {
353
364
  const line = hunk.lines[i];
354
365
  const no = String(
@@ -358,42 +369,29 @@ function renderUnifiedHunk(hunk: DiffHunk, layout: UnifiedLayout): string[] {
358
369
  if (line.type === "context") {
359
370
  const raw = truncateText(line.text, lineTextW);
360
371
  const text = lang ? highlightLine(raw, lang) : raw;
361
- // The flush gutter dims only the line number; the code stays normal/highlighted.
362
372
  out.push(!gutterLine ? `${p.dim}${no}${p.reset} ${text}` : `${gutter(no)} ${p.dim}${text}${p.reset}`);
363
373
  continue;
364
374
  }
365
375
 
376
+ const pair = pairs.get(i);
366
377
  if (line.type === "removed") {
367
- const pair = pairs.get(i);
368
378
  let removedText: string;
369
- let addedText: string | null = null;
370
- let addedNo: string | null = null;
371
-
372
- if (pair && pair.removedIdx === i) {
373
- const highlighted = highlightInlineChanges(
374
- line.text, pair.added.text, removedPalette, addedPalette, useTrueColor, lang,
375
- );
376
- removedText = truncateText(highlighted.old, lineTextW);
377
- addedText = truncateText(highlighted.new, lineTextW);
378
- addedNo = String(pair.added.newNo ?? "").padStart(noW);
379
- renderedAsPartOfPair.add(pair.addedIdx);
379
+ if (pair) {
380
+ removedText = truncateText(highlightedPair(pair).old, lineTextW);
380
381
  } else {
381
382
  const raw = truncateText(line.text, lineTextW);
382
383
  removedText = lang ? highlightLine(raw, lang) : raw;
383
384
  }
384
-
385
385
  out.push(change(no, "-", p.errorBg, p.error, removedText));
386
- if (addedText !== null && addedNo !== null) {
387
- out.push(change(addedNo, "+", p.successBg, p.success, addedText));
386
+ } else {
387
+ let addedText: string;
388
+ if (pair) {
389
+ addedText = truncateText(highlightedPair(pair).new, lineTextW);
390
+ } else {
391
+ const raw = truncateText(line.text, lineTextW);
392
+ addedText = lang ? highlightLine(raw, lang) : raw;
388
393
  }
389
- continue;
390
- }
391
-
392
- if (line.type === "added") {
393
- if (renderedAsPartOfPair.has(i)) continue;
394
- const raw = truncateText(line.text, lineTextW);
395
- const text = lang ? highlightLine(raw, lang) : raw;
396
- out.push(change(no, "+", p.successBg, p.success, text));
394
+ out.push(change(no, "+", p.successBg, p.success, addedText));
397
395
  }
398
396
  }
399
397
  return out;
@@ -478,7 +476,7 @@ function renderSplitHunk(hunk: DiffHunk, layout: SplitLayout): string[] {
478
476
  } else if (row.left.type === "removed") {
479
477
  if (useTrueColor) {
480
478
  leftCol = padToWidth(
481
- `${p.errorBg}${p.error}${leftNo} ${preserveBg(leftText, p.errorBg)}`, colWidth,
479
+ `${p.errorBg}${p.error}${leftNo} │${p.diffText} ${preserveBg(leftText, p.errorBg)}`, colWidth,
482
480
  ) + p.reset;
483
481
  } else {
484
482
  leftCol = padToWidth(`${p.error}${leftNo} │ ${leftText}${p.reset}`, colWidth);
@@ -492,7 +490,7 @@ function renderSplitHunk(hunk: DiffHunk, layout: SplitLayout): string[] {
492
490
  } else if (row.right.type === "added") {
493
491
  if (useTrueColor) {
494
492
  rightCol = padToWidth(
495
- `${p.successBg}${p.success}${rightNo} ${preserveBg(rightText, p.successBg)}`, colWidth,
493
+ `${p.successBg}${p.success}${rightNo} │${p.diffText} ${preserveBg(rightText, p.successBg)}`, colWidth,
496
494
  ) + p.reset;
497
495
  } else {
498
496
  rightCol = padToWidth(`${p.success}${rightNo} │ ${rightText}${p.reset}`, colWidth);
@@ -22,6 +22,7 @@ export interface ColorPalette {
22
22
  errorBg: string; // subtle red tint for removed lines
23
23
  successBgEmph: string; // stronger green for changed tokens
24
24
  errorBgEmph: string; // stronger red for changed tokens
25
+ diffText: string; // legible fg for diff row text on the tinted rows
25
26
 
26
27
  // ── Style modifiers ───────────────────────────────────────
27
28
  bold: string;
@@ -38,10 +39,11 @@ const defaultPalette: ColorPalette = {
38
39
  error: "\x1b[31m", // red
39
40
  muted: "\x1b[90m", // gray
40
41
 
41
- successBg: "\x1b[48;2;34;92;43m",
42
- errorBg: "\x1b[48;2;122;41;54m",
43
- successBgEmph: "\x1b[48;2;56;166;96m",
44
- errorBgEmph: "\x1b[48;2;179;89;107m",
42
+ successBg: "\x1b[48;2;26;70;34m",
43
+ errorBg: "\x1b[48;2;92;32;42m",
44
+ successBgEmph: "\x1b[48;2;38;104;56m",
45
+ errorBgEmph: "\x1b[48;2;124;50;64m",
46
+ diffText: "\x1b[97m", // bright white — readable on the red/green tints
45
47
 
46
48
  bold: "\x1b[1m",
47
49
  dim: "\x1b[2m",