feat(coding-agent): added middle-elision caps to OutputSink truncation
- Added `tools.artifactHeadBytes` and `tools.outputMaxColumns` settings with defaults in `SETTINGS_SCHEMA`. - Expanded `OutputSink` with `headBytes`/`maxColumns` and middle truncate logic with elision markers and tracking. - Updated output-meta to resolve sink settings, emit truncation metrics, and use `truncateMiddle` for spills. - Integrated head and column limits into JS/Python/Bash/SSH/read output flows, with `:raw` skipping read truncation. - Documented new output middle-elision and column-cap behavior in `CHANGELOG.md`. - Added truncation tests for `OutputSink`, `truncateMiddle`, and read-tool line handling.
This commit is contained in:
@@ -2,6 +2,11 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Added middle elision for streaming tool outputs (bash, ssh, python, js eval) and post-execution tool result spill. When `tools.artifactHeadBytes` is set (default 20 KB), large outputs now keep both the first N KB and the last N KB with an inline `[… N lines elided (M KB) …]` marker between them, instead of dropping everything before the trailing tail. Setting `tools.artifactHeadBytes = 0` reverts to the previous tail-only behavior. The full output is still mirrored to the session artifact (`artifact://<id>`) regardless of elision mode. Exposes `truncateMiddle` and `formatMiddleElisionMarker` from `@oh-my-pi/pi-coding-agent/session/streaming-output`, extends `OutputSinkOptions` with `headBytes`, and adds `direction: "middle"` plus `headRange` / `tailRange` / `elidedLines` / `elidedBytes` to `TruncationMeta`.
|
||||
- Added per-line column cap shared across streaming tool outputs (`bash`, `ssh`, `python`, `js eval`) and the `read` tool. Lines wider than `tools.outputMaxColumns` bytes (default **768**) are ellipsis-truncated at write time and remaining bytes up to the next `\n` are dropped — bounded memory even on multi-MB single-line outputs (e.g. `cat /dev/urandom`). The cap lives on `OutputSink` as the new `maxColumns` option, persists state across chunk boundaries so split-mid-line writes still respect the budget, and exposes `columnDroppedBytes` / `columnTruncatedLines` on `OutputSummary`. Middle-elision byte math subtracts column drops so the "elided from middle" count stays honest. `read` reuses the same setting but trims its already-collected lines via `truncateLine`. Skipped when the read selector is `:raw`. The artifact file (`artifact://<id>`) keeps the full uncapped stream. Set `tools.outputMaxColumns = 0` to disable.
|
||||
|
||||
## [15.0.0] - 2026-05-13
|
||||
### Breaking Changes
|
||||
|
||||
|
||||
@@ -460,6 +460,46 @@ export const SETTINGS_SCHEMA = {
|
||||
],
|
||||
},
|
||||
},
|
||||
"tools.artifactHeadBytes": {
|
||||
type: "number",
|
||||
default: 20,
|
||||
ui: {
|
||||
tab: "tools",
|
||||
label: "Artifact head size (KB)",
|
||||
description:
|
||||
"Amount of head content kept inline alongside the tail when output spills to artifact (middle elision). 0 disables — keep tail only.",
|
||||
options: [
|
||||
{ value: "0", label: "0 KB", description: "Disabled; tail-only truncation" },
|
||||
{ value: "1", label: "1 KB", description: "~250 tokens" },
|
||||
{ value: "2.5", label: "2.5 KB", description: "~625 tokens" },
|
||||
{ value: "5", label: "5 KB", description: "~1.25K tokens" },
|
||||
{ value: "10", label: "10 KB", description: "~2.5K tokens" },
|
||||
{ value: "20", label: "20 KB", description: "Default; ~5K tokens" },
|
||||
{ value: "50", label: "50 KB", description: "~12.5K tokens" },
|
||||
{ value: "100", label: "100 KB", description: "~25K tokens" },
|
||||
{ value: "200", label: "200 KB", description: "~50K tokens" },
|
||||
],
|
||||
},
|
||||
},
|
||||
"tools.outputMaxColumns": {
|
||||
type: "number",
|
||||
default: 768,
|
||||
ui: {
|
||||
tab: "tools",
|
||||
label: "Output column cap",
|
||||
description:
|
||||
"Per-line byte cap for streaming tool outputs (bash, ssh, python, js eval) and `read`. Lines wider than this are ellipsis-truncated; remaining bytes up to the next newline are dropped. 0 disables.",
|
||||
options: [
|
||||
{ value: "0", label: "Off", description: "No per-line cap" },
|
||||
{ value: "256", label: "256", description: "Tight" },
|
||||
{ value: "512", label: "512" },
|
||||
{ value: "768", label: "768", description: "Default" },
|
||||
{ value: "1024", label: "1024" },
|
||||
{ value: "2048", label: "2048" },
|
||||
{ value: "4096", label: "4096", description: "Loose" },
|
||||
],
|
||||
},
|
||||
},
|
||||
"tools.artifactTailLines": {
|
||||
type: "number",
|
||||
default: 500,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { DEFAULT_MAX_BYTES, OutputSink } from "../../session/streaming-output";
|
||||
import type { ToolSession } from "../../tools";
|
||||
import { resolveOutputMaxColumns, resolveOutputSinkHeadBytes } from "../../tools/output-meta";
|
||||
import { executeInVmContext, type JsDisplayOutput } from "./context-manager";
|
||||
|
||||
export interface JsExecutorOptions {
|
||||
@@ -49,6 +50,8 @@ export async function executeJs(code: string, options: JsExecutorOptions): Promi
|
||||
artifactPath: options.artifactPath,
|
||||
artifactId: options.artifactId,
|
||||
spillThreshold: DEFAULT_MAX_BYTES,
|
||||
headBytes: resolveOutputSinkHeadBytes(options.session.settings),
|
||||
maxColumns: resolveOutputMaxColumns(options.session.settings),
|
||||
onChunk: chunk => options.onChunk?.(chunk),
|
||||
});
|
||||
const timeoutMs = getExecutionTimeoutMs(options);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { getProjectDir, logger } from "@oh-my-pi/pi-utils";
|
||||
import { Settings } from "../../config/settings";
|
||||
import { OutputSink } from "../../session/streaming-output";
|
||||
import type { ToolSession } from "../../tools";
|
||||
import { resolveOutputMaxColumns, resolveOutputSinkHeadBytes } from "../../tools/output-meta";
|
||||
import type { JsStatusEvent } from "../js/shared/types";
|
||||
import type { KernelDisplayOutput } from "./display";
|
||||
import {
|
||||
@@ -815,10 +817,13 @@ async function executeWithKernel(
|
||||
code: string,
|
||||
options: PythonExecutorOptions | undefined,
|
||||
): Promise<PythonResult> {
|
||||
const settings = await Settings.init();
|
||||
const sink = new OutputSink({
|
||||
onChunk: options?.onChunk,
|
||||
artifactPath: options?.artifactPath,
|
||||
artifactId: options?.artifactId,
|
||||
headBytes: resolveOutputSinkHeadBytes(settings),
|
||||
maxColumns: resolveOutputMaxColumns(settings),
|
||||
});
|
||||
const displayOutputs: KernelDisplayOutput[] = [];
|
||||
const deadlineMs = getExecutionDeadlineMs(options);
|
||||
|
||||
@@ -7,6 +7,7 @@ import * as fs from "node:fs/promises";
|
||||
import { executeShell, type MinimizerOptions, Shell } from "@oh-my-pi/pi-natives";
|
||||
import { Settings, type ShellMinimizerSettings } from "../config/settings";
|
||||
import { OutputSink } from "../session/streaming-output";
|
||||
import { resolveOutputMaxColumns, resolveOutputSinkHeadBytes } from "../tools/output-meta";
|
||||
import { getOrCreateSnapshot } from "../utils/shell-snapshot";
|
||||
import { NON_INTERACTIVE_ENV } from "./non-interactive-env";
|
||||
|
||||
@@ -94,6 +95,8 @@ export async function executeBash(command: string, options?: BashExecutorOptions
|
||||
onChunk: options?.onChunk,
|
||||
artifactPath: options?.artifactPath,
|
||||
artifactId: options?.artifactId,
|
||||
headBytes: resolveOutputSinkHeadBytes(settings),
|
||||
maxColumns: resolveOutputMaxColumns(settings),
|
||||
// Throttle the streaming preview callback to avoid saturating the
|
||||
// event loop when commands produce massive output (e.g. seq 1 50M).
|
||||
chunkThrottleMs: options?.onChunk ? 50 : 0,
|
||||
|
||||
@@ -12,6 +12,7 @@ export const DEFAULT_MAX_BYTES = 50 * 1024; // 50KB
|
||||
export const DEFAULT_MAX_COLUMN = 1024; // Max chars per grep match line
|
||||
|
||||
const NL = "\n";
|
||||
const ELLIPSIS = "…";
|
||||
|
||||
// =============================================================================
|
||||
// Interfaces
|
||||
@@ -24,6 +25,14 @@ export interface OutputSummary {
|
||||
totalBytes: number;
|
||||
outputLines: number;
|
||||
outputBytes: number;
|
||||
/** Bytes elided from the middle when head-retain mode is active. */
|
||||
elidedBytes?: number;
|
||||
/** Lines elided from the middle when head-retain mode is active. */
|
||||
elidedLines?: number;
|
||||
/** Bytes dropped by the per-line column cap (sum across all lines). */
|
||||
columnDroppedBytes?: number;
|
||||
/** Number of distinct lines that hit the per-line column cap. */
|
||||
columnTruncatedLines?: number;
|
||||
/** Artifact ID for internal URL access (artifact://<id>) when truncated */
|
||||
artifactId?: string;
|
||||
}
|
||||
@@ -31,7 +40,21 @@ export interface OutputSummary {
|
||||
export interface OutputSinkOptions {
|
||||
artifactPath?: string;
|
||||
artifactId?: string;
|
||||
/** Tail buffer budget (bytes). Default DEFAULT_MAX_BYTES. */
|
||||
spillThreshold?: number;
|
||||
/**
|
||||
* When > 0, the sink keeps the first `headBytes` of output in addition to
|
||||
* the rolling tail window. Output between the two windows is elided
|
||||
* (middle elision). Default 0 = tail-only behavior.
|
||||
*/
|
||||
headBytes?: number;
|
||||
/**
|
||||
* Per-line byte cap. When > 0, lines wider than `maxColumns` bytes are
|
||||
* truncated with an ellipsis at write time; remaining bytes up to the next
|
||||
* `\n` are dropped. Cap state persists across chunks so split-mid-line
|
||||
* writes still respect the budget. Default 0 = no per-line cap.
|
||||
*/
|
||||
maxColumns?: number;
|
||||
onChunk?: (chunk: string) => void;
|
||||
/** Minimum ms between onChunk calls. 0 = every chunk (default). */
|
||||
chunkThrottleMs?: number;
|
||||
@@ -40,11 +63,15 @@ export interface OutputSinkOptions {
|
||||
export interface TruncationResult {
|
||||
content: string;
|
||||
truncated?: boolean;
|
||||
truncatedBy?: "lines" | "bytes";
|
||||
truncatedBy?: "lines" | "bytes" | "middle";
|
||||
totalLines: number;
|
||||
totalBytes: number;
|
||||
outputLines?: number;
|
||||
outputBytes?: number;
|
||||
/** Bytes elided from the middle (truncateMiddle only). */
|
||||
elidedBytes?: number;
|
||||
/** Lines elided from the middle (truncateMiddle only). */
|
||||
elidedLines?: number;
|
||||
lastLinePartial?: boolean;
|
||||
firstLineExceedsLimit?: boolean;
|
||||
}
|
||||
@@ -54,6 +81,16 @@ export interface TruncationOptions {
|
||||
maxLines?: number;
|
||||
/** Maximum number of bytes (default: 50KB) */
|
||||
maxBytes?: number;
|
||||
/**
|
||||
* For `truncateMiddle`: bytes reserved for the head window. The tail
|
||||
* window receives `maxBytes - maxHeadBytes`. Default `floor(maxBytes/2)`.
|
||||
*/
|
||||
maxHeadBytes?: number;
|
||||
/**
|
||||
* For `truncateMiddle`: lines reserved for the head window. The tail
|
||||
* window receives `maxLines - maxHeadLines`. Default `floor(maxLines/2)`.
|
||||
*/
|
||||
maxHeadLines?: number;
|
||||
}
|
||||
|
||||
/** Result from byte-level truncation helpers. */
|
||||
@@ -425,6 +462,90 @@ export function truncateTail(content: string, options: TruncationOptions = {}):
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Middle elision (keep head + tail, drop middle)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Format the inline marker substituted for the elided middle region.
|
||||
* Returned without surrounding newlines so callers can position it freely.
|
||||
*/
|
||||
export function formatMiddleElisionMarker(elidedLines: number, elidedBytes: number): string {
|
||||
const linesPart = `${elidedLines.toLocaleString()} line${elidedLines === 1 ? "" : "s"}`;
|
||||
return `[… ${linesPart} elided (${formatBytes(elidedBytes)}) …]`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate content keeping a head window and a tail window, eliding the middle.
|
||||
*
|
||||
* The combined output is `<head>\n<marker>\n<tail>` when truncation is needed.
|
||||
* `maxHeadBytes` defaults to `floor(maxBytes / 2)`; the tail receives the
|
||||
* remainder. Falls back to `truncateTail` / `truncateHead` if either side's
|
||||
* budget is empty or the content already fits.
|
||||
*/
|
||||
export function truncateMiddle(content: string, options: TruncationOptions = {}): TruncationResult {
|
||||
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
|
||||
const maxLines = options.maxLines ?? DEFAULT_MAX_LINES;
|
||||
const headBytes = options.maxHeadBytes ?? Math.floor(maxBytes / 2);
|
||||
const tailBytes = Math.max(0, maxBytes - headBytes);
|
||||
const headLines = options.maxHeadLines ?? Math.max(1, Math.floor(maxLines / 2));
|
||||
const tailLines = Math.max(0, maxLines - headLines);
|
||||
|
||||
const totalBytes = Buffer.byteLength(content, "utf-8");
|
||||
const totalLines = countNewlines(content) + 1;
|
||||
|
||||
if (totalBytes <= maxBytes && totalLines <= maxLines) {
|
||||
return noTruncResult(content, totalLines, totalBytes);
|
||||
}
|
||||
|
||||
// Degenerate budgets → fall back to one-sided truncation.
|
||||
if (headBytes <= 0 || headLines <= 0) {
|
||||
return truncateTail(content, { maxBytes: tailBytes || maxBytes, maxLines: tailLines || maxLines });
|
||||
}
|
||||
if (tailBytes <= 0 || tailLines <= 0) {
|
||||
return truncateHead(content, { maxBytes: headBytes, maxLines: headLines });
|
||||
}
|
||||
|
||||
const head = truncateHead(content, { maxBytes: headBytes, maxLines: headLines });
|
||||
const tail = truncateTail(content, { maxBytes: tailBytes, maxLines: tailLines });
|
||||
|
||||
const headLinesKept = head.outputLines ?? 0;
|
||||
const tailLinesKept = tail.outputLines ?? 0;
|
||||
const headBytesKept = head.outputBytes ?? Buffer.byteLength(head.content, "utf-8");
|
||||
const tailBytesKept = tail.outputBytes ?? Buffer.byteLength(tail.content, "utf-8");
|
||||
|
||||
// Head unusable (first line exceeds budget) → tail-only.
|
||||
if (headLinesKept === 0 || head.firstLineExceedsLimit) return tail;
|
||||
// Tail unusable → head-only.
|
||||
if (tailLinesKept === 0) return head;
|
||||
// Windows overlap → no meaningful elision; return content untruncated.
|
||||
if (headLinesKept + tailLinesKept >= totalLines) {
|
||||
return noTruncResult(content, totalLines, totalBytes);
|
||||
}
|
||||
|
||||
const elidedLines = totalLines - headLinesKept - tailLinesKept;
|
||||
// `totalBytes - headBytesKept - tailBytesKept` includes newline separators
|
||||
// between the kept windows and the elided region; close enough for a notice.
|
||||
const elidedBytes = Math.max(0, totalBytes - headBytesKept - tailBytesKept);
|
||||
const marker = formatMiddleElisionMarker(elidedLines, elidedBytes);
|
||||
const composed = `${head.content}\n${marker}\n${tail.content}`;
|
||||
const markerBytes = Buffer.byteLength(marker, "utf-8");
|
||||
|
||||
return {
|
||||
content: composed,
|
||||
truncated: true,
|
||||
truncatedBy: "middle",
|
||||
totalLines,
|
||||
totalBytes,
|
||||
outputLines: headLinesKept + tailLinesKept + 1,
|
||||
outputBytes: headBytesKept + tailBytesKept + markerBytes + 2,
|
||||
elidedLines,
|
||||
elidedBytes,
|
||||
lastLinePartial: tail.lastLinePartial,
|
||||
firstLineExceedsLimit: false,
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// TailBuffer — ring-style tail buffer with lazy joining
|
||||
// =============================================================================
|
||||
@@ -520,12 +641,21 @@ export class TailBuffer {
|
||||
export class OutputSink {
|
||||
#buffer = "";
|
||||
#bufferBytes = 0;
|
||||
#head = "";
|
||||
#headBytes = 0;
|
||||
#headLines = 0; // newline count inside #head
|
||||
#totalLines = 0; // newline count
|
||||
#totalBytes = 0;
|
||||
#sawData = false;
|
||||
#truncated = false;
|
||||
#lastChunkTime = 0;
|
||||
|
||||
// Per-line column cap streaming state (persists across `push` calls so a
|
||||
// long line split across chunks still trips the same trigger).
|
||||
#currentLineBytes = 0;
|
||||
#columnEllipsisAdded = false;
|
||||
#columnDroppedBytes = 0;
|
||||
#columnTruncatedLines = 0;
|
||||
#file?: {
|
||||
path: string;
|
||||
artifactId?: string;
|
||||
@@ -539,20 +669,26 @@ export class OutputSink {
|
||||
readonly #artifactPath?: string;
|
||||
readonly #artifactId?: string;
|
||||
readonly #spillThreshold: number;
|
||||
readonly #headLimit: number;
|
||||
readonly #onChunk?: (chunk: string) => void;
|
||||
readonly #chunkThrottleMs: number;
|
||||
readonly #maxColumns: number;
|
||||
|
||||
constructor(options?: OutputSinkOptions) {
|
||||
const {
|
||||
artifactPath,
|
||||
artifactId,
|
||||
spillThreshold = DEFAULT_MAX_BYTES,
|
||||
headBytes = 0,
|
||||
maxColumns = 0,
|
||||
onChunk,
|
||||
chunkThrottleMs = 0,
|
||||
} = options ?? {};
|
||||
this.#artifactPath = artifactPath;
|
||||
this.#artifactId = artifactId;
|
||||
this.#spillThreshold = spillThreshold;
|
||||
this.#headLimit = Math.max(0, headBytes);
|
||||
this.#maxColumns = Math.max(0, maxColumns);
|
||||
this.#onChunk = onChunk;
|
||||
this.#chunkThrottleMs = chunkThrottleMs;
|
||||
}
|
||||
@@ -565,6 +701,8 @@ export class OutputSink {
|
||||
chunk = sanitizeWithOptionalSixelPassthrough(chunk, sanitizeText);
|
||||
|
||||
// Throttled onChunk: only call the callback when enough time has passed.
|
||||
// Live preview gets the raw (pre-cap) chunk so the TUI never lags behind
|
||||
// what reached the sink — the column cap is for the persisted LLM view.
|
||||
if (this.#onChunk) {
|
||||
const now = Date.now();
|
||||
if (now - this.#lastChunkTime >= this.#chunkThrottleMs) {
|
||||
@@ -573,22 +711,124 @@ export class OutputSink {
|
||||
}
|
||||
}
|
||||
|
||||
const dataBytes = Buffer.byteLength(chunk, "utf-8");
|
||||
this.#totalBytes += dataBytes;
|
||||
const rawBytes = Buffer.byteLength(chunk, "utf-8");
|
||||
this.#totalBytes += rawBytes;
|
||||
|
||||
if (chunk.length > 0) {
|
||||
this.#sawData = true;
|
||||
this.#totalLines += countNewlines(chunk);
|
||||
}
|
||||
|
||||
const threshold = this.#spillThreshold;
|
||||
const willOverflow = this.#bufferBytes + dataBytes > threshold;
|
||||
// Per-line column cap. State persists across chunks so a mid-line split
|
||||
// still respects the budget. Operates on the sanitized chunk; the cap is
|
||||
// applied before head/tail accounting but after artifact mirroring decides.
|
||||
const capped = this.#maxColumns > 0 ? this.#applyColumnCap(chunk) : chunk;
|
||||
const cappedBytes = capped === chunk ? rawBytes : Buffer.byteLength(capped, "utf-8");
|
||||
const cappedThisChunk = cappedBytes < rawBytes;
|
||||
if (cappedThisChunk) this.#truncated = true;
|
||||
|
||||
// Write to artifact file if configured and past the threshold
|
||||
if (this.#artifactPath && (this.#file != null || willOverflow)) {
|
||||
// Mirror RAW chunk to the artifact file so the on-disk record is the full
|
||||
// uncapped stream. Mirror triggers on: in-memory overflow OR this chunk's
|
||||
// column cap dropped bytes (otherwise we'd lose data) OR file already open.
|
||||
if (this.#artifactPath && (this.#file != null || cappedThisChunk || this.#willOverflow(cappedBytes))) {
|
||||
this.#writeToFile(chunk);
|
||||
}
|
||||
|
||||
if (cappedBytes === 0) return;
|
||||
|
||||
// Head retention: drain the (capped) chunk into #head until the budget is
|
||||
// exhausted, then forward any leftover to the tail buffer.
|
||||
let tailChunk = capped;
|
||||
let tailBytes = cappedBytes;
|
||||
if (this.#headLimit > 0 && this.#headBytes < this.#headLimit) {
|
||||
const room = this.#headLimit - this.#headBytes;
|
||||
if (cappedBytes <= room) {
|
||||
this.#head += capped;
|
||||
this.#headBytes += cappedBytes;
|
||||
this.#headLines += countNewlines(capped);
|
||||
return;
|
||||
}
|
||||
// Split: head takes a UTF-8-safe prefix; remainder flows to tail.
|
||||
const headSlice = truncateHeadBytes(capped, room);
|
||||
if (headSlice.bytes > 0) {
|
||||
this.#head += headSlice.text;
|
||||
this.#headBytes += headSlice.bytes;
|
||||
this.#headLines += countNewlines(headSlice.text);
|
||||
tailChunk = capped.substring(headSlice.text.length);
|
||||
tailBytes = cappedBytes - headSlice.bytes;
|
||||
}
|
||||
}
|
||||
|
||||
this.#pushTail(tailChunk, tailBytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the per-line byte cap to `chunk`, dropping bytes that would push the
|
||||
* current line beyond `#maxColumns`. Emits a single `…` once a line trips the
|
||||
* cap; subsequent bytes are skipped until the next `\n`. State persists
|
||||
* across calls so a long line split across chunks still produces one marker.
|
||||
*/
|
||||
#applyColumnCap(chunk: string): string {
|
||||
if (chunk.length === 0) return chunk;
|
||||
const max = this.#maxColumns;
|
||||
const parts: string[] = [];
|
||||
let cursor = 0;
|
||||
while (cursor < chunk.length) {
|
||||
const nlIdx = chunk.indexOf(NL, cursor);
|
||||
const segEnd = nlIdx === -1 ? chunk.length : nlIdx;
|
||||
if (segEnd > cursor) {
|
||||
const segment = chunk.substring(cursor, segEnd);
|
||||
if (this.#columnEllipsisAdded) {
|
||||
// Past the cap; drop until newline.
|
||||
this.#columnDroppedBytes += Buffer.byteLength(segment, "utf-8");
|
||||
} else {
|
||||
const segBytes = Buffer.byteLength(segment, "utf-8");
|
||||
const remaining = max - this.#currentLineBytes;
|
||||
if (segBytes <= remaining) {
|
||||
parts.push(segment);
|
||||
this.#currentLineBytes += segBytes;
|
||||
} else {
|
||||
// First overflow on this line: keep what fits, append ellipsis,
|
||||
// arm the skip-until-newline flag.
|
||||
const ellipsisBytes = 3; // "…" in UTF-8
|
||||
const headRoom = Math.max(0, remaining - ellipsisBytes);
|
||||
let kept = "";
|
||||
let keptBytes = 0;
|
||||
if (headRoom > 0) {
|
||||
const sliced = truncateHeadBytes(segment, headRoom);
|
||||
kept = sliced.text;
|
||||
keptBytes = sliced.bytes;
|
||||
parts.push(kept);
|
||||
}
|
||||
parts.push(ELLIPSIS);
|
||||
this.#columnDroppedBytes += segBytes - keptBytes;
|
||||
this.#columnTruncatedLines++;
|
||||
this.#currentLineBytes += keptBytes + ellipsisBytes;
|
||||
this.#columnEllipsisAdded = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (nlIdx === -1) break;
|
||||
parts.push(NL);
|
||||
this.#currentLineBytes = 0;
|
||||
this.#columnEllipsisAdded = false;
|
||||
cursor = nlIdx + 1;
|
||||
}
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
#willOverflow(dataBytes: number): boolean {
|
||||
// Triggers file mirroring as soon as the next chunk would push us over
|
||||
// the tail budget (head retention does not change spill-to-artifact).
|
||||
return this.#bufferBytes + dataBytes > this.#spillThreshold;
|
||||
}
|
||||
|
||||
#pushTail(chunk: string, dataBytes: number): void {
|
||||
if (dataBytes === 0) return;
|
||||
|
||||
const threshold = this.#spillThreshold;
|
||||
const willOverflow = this.#bufferBytes + dataBytes > threshold;
|
||||
|
||||
if (!willOverflow) {
|
||||
this.#buffer += chunk;
|
||||
this.#bufferBytes += dataBytes;
|
||||
@@ -612,8 +852,6 @@ export class OutputSink {
|
||||
this.#buffer = text;
|
||||
this.#bufferBytes = bytes;
|
||||
}
|
||||
|
||||
if (this.#file) this.#truncated = true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -685,26 +923,84 @@ export class OutputSink {
|
||||
* streaming counters (totalLines/totalBytes reflect the raw chunks that
|
||||
* already reached the sink). Used when an upstream minimizer rewrites the
|
||||
* captured output after the raw bytes have already been streamed.
|
||||
*
|
||||
* Clears any retained head window — the minimized text is authoritative.
|
||||
*/
|
||||
replace(text: string): void {
|
||||
this.#buffer = text;
|
||||
this.#bufferBytes = Buffer.byteLength(text, "utf-8");
|
||||
this.#head = "";
|
||||
this.#headBytes = 0;
|
||||
this.#headLines = 0;
|
||||
this.#currentLineBytes = 0;
|
||||
this.#columnEllipsisAdded = false;
|
||||
this.#columnDroppedBytes = 0;
|
||||
this.#columnTruncatedLines = 0;
|
||||
}
|
||||
|
||||
async dump(notice?: string): Promise<OutputSummary> {
|
||||
const noticeLine = notice ? `[${notice}]\n` : "";
|
||||
const outputLines = this.#buffer.length > 0 ? countNewlines(this.#buffer) + 1 : 0;
|
||||
const totalLines = this.#sawData ? this.#totalLines + 1 : 0;
|
||||
|
||||
if (this.#file) await this.#file.sink.end();
|
||||
|
||||
// Compose the visible output. With head retention, splice head + marker
|
||||
// + tail when content was elided. Otherwise return the rolling buffer.
|
||||
const headBytes = this.#headBytes;
|
||||
const tailBuf = this.#buffer;
|
||||
const tailBytes = this.#bufferBytes;
|
||||
const headLines = this.#headLines + (headBytes > 0 && !this.#head.endsWith("\n") ? 1 : 0);
|
||||
const tailLines = tailBuf.length > 0 ? countNewlines(tailBuf) + 1 : 0;
|
||||
|
||||
// Bytes that survived the column cap. Middle elision operates on these,
|
||||
// so column-dropped bytes don't inflate the "elided from middle" count.
|
||||
const effectiveTotalBytes = Math.max(0, this.#totalBytes - this.#columnDroppedBytes);
|
||||
|
||||
let body: string;
|
||||
let outputBytes: number;
|
||||
let outputLines: number;
|
||||
let elidedBytes: number | undefined;
|
||||
let elidedLines: number | undefined;
|
||||
|
||||
if (headBytes > 0 && effectiveTotalBytes > headBytes + tailBytes) {
|
||||
// Middle was elided. Emit head + marker + tail.
|
||||
elidedBytes = Math.max(0, effectiveTotalBytes - headBytes - tailBytes);
|
||||
elidedLines = Math.max(0, totalLines - headLines - tailLines);
|
||||
const marker = formatMiddleElisionMarker(elidedLines, elidedBytes);
|
||||
const markerBytes = Buffer.byteLength(marker, "utf-8");
|
||||
const headSep = this.#head.endsWith("\n") ? "" : "\n";
|
||||
const tailSep = tailBuf.startsWith("\n") ? "" : "\n";
|
||||
body = `${this.#head}${headSep}${marker}${tailSep}${tailBuf}`;
|
||||
outputBytes =
|
||||
headBytes +
|
||||
markerBytes +
|
||||
tailBytes +
|
||||
Buffer.byteLength(headSep, "utf-8") +
|
||||
Buffer.byteLength(tailSep, "utf-8");
|
||||
outputLines = headLines + 1 + tailLines;
|
||||
this.#truncated = true;
|
||||
} else if (headBytes > 0) {
|
||||
// Head + tail combine into the full buffered output (no overlap or elision).
|
||||
body = `${this.#head}${tailBuf}`;
|
||||
outputBytes = headBytes + tailBytes;
|
||||
outputLines = body.length > 0 ? countNewlines(body) + 1 : 0;
|
||||
} else {
|
||||
body = tailBuf;
|
||||
outputBytes = tailBytes;
|
||||
outputLines = tailLines;
|
||||
}
|
||||
|
||||
return {
|
||||
output: `${noticeLine}${this.#buffer}`,
|
||||
output: `${noticeLine}${body}`,
|
||||
truncated: this.#truncated,
|
||||
totalLines,
|
||||
totalBytes: this.#totalBytes,
|
||||
outputLines,
|
||||
outputBytes: this.#bufferBytes,
|
||||
outputBytes,
|
||||
elidedBytes,
|
||||
elidedLines,
|
||||
columnDroppedBytes: this.#columnDroppedBytes > 0 ? this.#columnDroppedBytes : undefined,
|
||||
columnTruncatedLines: this.#columnTruncatedLines > 0 ? this.#columnTruncatedLines : undefined,
|
||||
artifactId: this.#file?.artifactId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { logger, ptree } from "@oh-my-pi/pi-utils";
|
||||
import { Settings } from "../config/settings";
|
||||
import { OutputSink } from "../session/streaming-output";
|
||||
import { resolveOutputMaxColumns, resolveOutputSinkHeadBytes } from "../tools/output-meta";
|
||||
import { buildRemoteCommand, ensureConnection, ensureHostInfo, type SSHConnectionTarget } from "./connection-manager";
|
||||
import { hasSshfs, mountRemote } from "./sshfs-mount";
|
||||
|
||||
@@ -83,10 +85,13 @@ export async function executeSSH(
|
||||
stderr: "full",
|
||||
});
|
||||
|
||||
const settings = await Settings.init();
|
||||
const sink = new OutputSink({
|
||||
onChunk: options?.onChunk,
|
||||
artifactPath: options?.artifactPath,
|
||||
artifactId: options?.artifactId,
|
||||
headBytes: resolveOutputSinkHeadBytes(settings),
|
||||
maxColumns: resolveOutputMaxColumns(settings),
|
||||
});
|
||||
|
||||
const streams = [child.stdout.pipeTo(sink.createInput())];
|
||||
|
||||
@@ -12,10 +12,12 @@ import {
|
||||
} from "@oh-my-pi/pi-tui";
|
||||
import type { Terminal as XtermTerminalType } from "@xterm/headless";
|
||||
import xterm from "@xterm/headless";
|
||||
import { Settings } from "../config/settings";
|
||||
import { NON_INTERACTIVE_ENV } from "../exec/non-interactive-env";
|
||||
import type { Theme } from "../modes/theme/theme";
|
||||
import { OutputSink, type OutputSummary } from "../session/streaming-output";
|
||||
import { sanitizeWithOptionalSixelPassthrough } from "../utils/sixel";
|
||||
import { resolveOutputMaxColumns, resolveOutputSinkHeadBytes } from "./output-meta";
|
||||
import { formatStatusIcon, replaceTabs } from "./render-utils";
|
||||
|
||||
export interface BashInteractiveResult extends OutputSummary {
|
||||
@@ -294,7 +296,13 @@ export async function runInteractiveBashPty(
|
||||
artifactId?: string;
|
||||
},
|
||||
): Promise<BashInteractiveResult> {
|
||||
const sink = new OutputSink({ artifactPath: options.artifactPath, artifactId: options.artifactId });
|
||||
const settings = await Settings.init();
|
||||
const sink = new OutputSink({
|
||||
artifactPath: options.artifactPath,
|
||||
artifactId: options.artifactId,
|
||||
headBytes: resolveOutputSinkHeadBytes(settings),
|
||||
maxColumns: resolveOutputMaxColumns(settings),
|
||||
});
|
||||
const result = await ui.custom<BashInteractiveResult>(
|
||||
(tui, uiTheme, _keybindings, done) => {
|
||||
const session = new PtySession();
|
||||
|
||||
@@ -16,7 +16,7 @@ import evalDescription from "../prompts/tools/eval.md" with { type: "text" };
|
||||
import { DEFAULT_MAX_BYTES, OutputSink, type OutputSummary, TailBuffer } from "../session/streaming-output";
|
||||
import { getTreeBranch, getTreeContinuePrefix, renderCodeCell } from "../tui";
|
||||
import { resolveEvalBackends, type ToolSession } from ".";
|
||||
import { formatStyledTruncationWarning } from "./output-meta";
|
||||
import { formatStyledTruncationWarning, resolveOutputMaxColumns, resolveOutputSinkHeadBytes } from "./output-meta";
|
||||
import { formatTitle, replaceTabs, shortenPath, truncateToWidth, wrapBrackets } from "./render-utils";
|
||||
import { ToolAbortError, ToolError } from "./tool-errors";
|
||||
import { toolResult } from "./tool-result";
|
||||
@@ -358,6 +358,8 @@ export class EvalTool implements AgentTool<typeof evalSchema> {
|
||||
outputSink = new OutputSink({
|
||||
artifactPath,
|
||||
artifactId,
|
||||
headBytes: resolveOutputSinkHeadBytes(session.settings),
|
||||
maxColumns: resolveOutputMaxColumns(session.settings),
|
||||
onChunk: chunk => {
|
||||
appendTail(chunk);
|
||||
pushUpdate();
|
||||
|
||||
@@ -15,7 +15,7 @@ import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai";
|
||||
import { getDefault, type Settings } from "../config/settings";
|
||||
import { formatGroupedDiagnosticMessages } from "../lsp/utils";
|
||||
import type { Theme } from "../modes/theme/theme";
|
||||
import { type OutputSummary, type TruncationResult, truncateTail } from "../session/streaming-output";
|
||||
import { type OutputSummary, type TruncationResult, truncateMiddle, truncateTail } from "../session/streaming-output";
|
||||
import { formatBytes, wrapBrackets } from "./render-utils";
|
||||
import { renderError } from "./tool-errors";
|
||||
|
||||
@@ -23,15 +23,22 @@ import { renderError } from "./tool-errors";
|
||||
* Truncation metadata for the output notice.
|
||||
*/
|
||||
export interface TruncationMeta {
|
||||
direction: "head" | "tail";
|
||||
truncatedBy: "lines" | "bytes";
|
||||
direction: "head" | "tail" | "middle";
|
||||
truncatedBy: "lines" | "bytes" | "middle";
|
||||
totalLines: number;
|
||||
totalBytes: number;
|
||||
outputLines: number;
|
||||
outputBytes: number;
|
||||
maxBytes?: number;
|
||||
/** Line range shown (1-indexed, inclusive) */
|
||||
/** Line range shown (1-indexed, inclusive). Omitted for middle elision. */
|
||||
shownRange?: { start: number; end: number };
|
||||
/** Head/tail line ranges shown when direction === "middle". */
|
||||
headRange?: { start: number; end: number };
|
||||
tailRange?: { start: number; end: number };
|
||||
/** Bytes elided from the middle. */
|
||||
elidedBytes?: number;
|
||||
/** Lines elided from the middle. */
|
||||
elidedLines?: number;
|
||||
/** Artifact ID if full output was saved */
|
||||
artifactId?: string;
|
||||
/** Next offset for pagination (head truncation only) */
|
||||
@@ -79,20 +86,20 @@ export interface OutputMeta {
|
||||
// =============================================================================
|
||||
|
||||
export interface TruncationOptions {
|
||||
direction: "head" | "tail";
|
||||
direction: "head" | "tail" | "middle";
|
||||
startLine?: number;
|
||||
totalFileLines?: number;
|
||||
artifactId?: string;
|
||||
}
|
||||
|
||||
export interface TruncationSummaryOptions {
|
||||
direction: "head" | "tail";
|
||||
direction: "head" | "tail" | "middle";
|
||||
startLine?: number;
|
||||
totalFileLines?: number;
|
||||
}
|
||||
|
||||
export interface TruncationTextOptions {
|
||||
direction: "head" | "tail";
|
||||
direction: "head" | "tail" | "middle";
|
||||
totalLines?: number;
|
||||
totalBytes?: number;
|
||||
maxBytes?: number;
|
||||
@@ -120,7 +127,40 @@ export class OutputMetaBuilder {
|
||||
const { direction, startLine = 1, totalFileLines, artifactId } = options;
|
||||
const outputLines = result.outputLines ?? result.totalLines;
|
||||
const outputBytes = result.outputBytes ?? result.totalBytes;
|
||||
const truncatedBy: "lines" | "bytes" = result.truncatedBy === "lines" ? "lines" : "bytes";
|
||||
const isMiddle = direction === "middle" || result.truncatedBy === "middle";
|
||||
const truncatedBy: "lines" | "bytes" | "middle" = isMiddle
|
||||
? "middle"
|
||||
: result.truncatedBy === "lines"
|
||||
? "lines"
|
||||
: "bytes";
|
||||
|
||||
const effectiveTotalLines = totalFileLines ?? result.totalLines;
|
||||
|
||||
if (isMiddle) {
|
||||
const elidedLines = result.elidedLines ?? Math.max(0, effectiveTotalLines - outputLines);
|
||||
const elidedBytes = result.elidedBytes ?? Math.max(0, result.totalBytes - outputBytes);
|
||||
// Reconstruct head/tail line ranges. The kept output spans the first
|
||||
// `headLines` lines and the last `tailLines` lines of the source; lines
|
||||
// in the middle (count == elidedLines) are dropped.
|
||||
const keptLines = Math.max(0, outputLines - 1); // -1 for marker line
|
||||
const headLines = Math.ceil(keptLines / 2);
|
||||
const tailLines = keptLines - headLines;
|
||||
this.#meta.truncation = {
|
||||
direction: "middle",
|
||||
truncatedBy: "middle",
|
||||
totalLines: effectiveTotalLines,
|
||||
totalBytes: result.totalBytes,
|
||||
outputLines,
|
||||
outputBytes,
|
||||
headRange: headLines > 0 ? { start: 1, end: headLines } : undefined,
|
||||
tailRange:
|
||||
tailLines > 0 ? { start: effectiveTotalLines - tailLines + 1, end: effectiveTotalLines } : undefined,
|
||||
elidedLines,
|
||||
elidedBytes,
|
||||
artifactId,
|
||||
};
|
||||
return this;
|
||||
}
|
||||
|
||||
let shownStart: number;
|
||||
let shownEnd: number;
|
||||
@@ -136,7 +176,7 @@ export class OutputMetaBuilder {
|
||||
this.#meta.truncation = {
|
||||
direction,
|
||||
truncatedBy,
|
||||
totalLines: totalFileLines ?? result.totalLines,
|
||||
totalLines: effectiveTotalLines,
|
||||
totalBytes: result.totalBytes,
|
||||
outputLines,
|
||||
outputBytes,
|
||||
@@ -154,6 +194,29 @@ export class OutputMetaBuilder {
|
||||
|
||||
const { direction, startLine = 1, totalFileLines } = options;
|
||||
const totalLines = totalFileLines ?? summary.totalLines;
|
||||
|
||||
// Middle elision: the sink retained head + tail with an elision marker.
|
||||
if (summary.elidedBytes != null && summary.elidedBytes > 0) {
|
||||
const elidedLines = summary.elidedLines ?? Math.max(0, totalLines - summary.outputLines);
|
||||
const keptLines = Math.max(0, summary.outputLines - 1); // -1 for marker line
|
||||
const headLines = Math.ceil(keptLines / 2);
|
||||
const tailLines = keptLines - headLines;
|
||||
this.#meta.truncation = {
|
||||
direction: "middle",
|
||||
truncatedBy: "middle",
|
||||
totalLines,
|
||||
totalBytes: summary.totalBytes,
|
||||
outputLines: summary.outputLines,
|
||||
outputBytes: summary.outputBytes,
|
||||
headRange: headLines > 0 ? { start: 1, end: headLines } : undefined,
|
||||
tailRange: tailLines > 0 ? { start: totalLines - tailLines + 1, end: totalLines } : undefined,
|
||||
elidedBytes: summary.elidedBytes,
|
||||
elidedLines,
|
||||
artifactId: summary.artifactId,
|
||||
};
|
||||
return this;
|
||||
}
|
||||
|
||||
const truncatedBy: "lines" | "bytes" =
|
||||
summary.outputBytes < summary.totalBytes
|
||||
? "bytes"
|
||||
@@ -322,9 +385,28 @@ export function formatFullOutputReference(artifactId: string): string {
|
||||
}
|
||||
|
||||
export function formatTruncationMetaNotice(truncation: TruncationMeta): string {
|
||||
const range = truncation.shownRange;
|
||||
let notice: string;
|
||||
|
||||
if (truncation.direction === "middle") {
|
||||
const head = truncation.headRange;
|
||||
const tail = truncation.tailRange;
|
||||
const totalLines = truncation.totalLines;
|
||||
const elidedBytes = truncation.elidedBytes ?? Math.max(0, truncation.totalBytes - truncation.outputBytes);
|
||||
const elidedLines = truncation.elidedLines ?? Math.max(0, totalLines - truncation.outputLines);
|
||||
const headPart = head ? `lines ${head.start}-${head.end}` : "";
|
||||
const tailPart = tail ? `${tail.start}-${tail.end}` : "";
|
||||
if (headPart && tailPart) {
|
||||
notice = `Showing ${headPart} and ${tailPart} of ${totalLines}; ${elidedLines.toLocaleString()} middle line${elidedLines === 1 ? "" : "s"} (${formatBytes(elidedBytes)}) elided`;
|
||||
} else {
|
||||
notice = `Showing ${truncation.outputLines} of ${totalLines} lines; middle elided`;
|
||||
}
|
||||
if (truncation.artifactId != null) {
|
||||
notice += `. ${formatFullOutputReference(truncation.artifactId)}`;
|
||||
}
|
||||
return notice;
|
||||
}
|
||||
|
||||
const range = truncation.shownRange;
|
||||
if (range && range.end >= range.start) {
|
||||
notice = `Showing lines ${range.start}-${range.end} of ${truncation.totalLines}`;
|
||||
} else {
|
||||
@@ -442,21 +524,44 @@ const kUnwrappedExecute = Symbol("OutputMeta.UnwrappedExecute");
|
||||
|
||||
/** Resolved artifact spill config sourced from the session settings (or schema defaults). */
|
||||
function getSpillConfig(s: Settings | undefined) {
|
||||
const get = <P extends "tools.artifactSpillThreshold" | "tools.artifactTailBytes" | "tools.artifactTailLines">(
|
||||
path: P,
|
||||
) => s?.get(path) ?? getDefault(path);
|
||||
type Path =
|
||||
| "tools.artifactSpillThreshold"
|
||||
| "tools.artifactTailBytes"
|
||||
| "tools.artifactTailLines"
|
||||
| "tools.artifactHeadBytes";
|
||||
const get = <P extends Path>(path: P) => s?.get(path) ?? getDefault(path);
|
||||
return {
|
||||
threshold: get("tools.artifactSpillThreshold") * 1024,
|
||||
tailBytes: get("tools.artifactTailBytes") * 1024,
|
||||
tailLines: get("tools.artifactTailLines"),
|
||||
headBytes: get("tools.artifactHeadBytes") * 1024,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* If the tool result text exceeds RESULT_ARTIFACT_THRESHOLD, save the full
|
||||
* output as a session artifact and replace the content with a tail-truncated
|
||||
* version plus an artifact reference. Skips when the tool already saved its
|
||||
* own artifact (e.g. bash/python via OutputSink).
|
||||
* Resolve the OutputSink `headBytes` budget from session settings.
|
||||
* Exposed so streaming executors (bash/python/ssh/eval) can opt into
|
||||
* middle elision with the same per-user configuration.
|
||||
*/
|
||||
export function resolveOutputSinkHeadBytes(s: Settings | undefined): number {
|
||||
return getSpillConfig(s).headBytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the per-line column cap from session settings. Shared by streaming
|
||||
* executors (bash/python/ssh/eval via OutputSink) and the `read` tool's
|
||||
* line-buffer post-processing, so one setting controls both surfaces.
|
||||
*/
|
||||
export function resolveOutputMaxColumns(s: Settings | undefined): number {
|
||||
return s?.get("tools.outputMaxColumns") ?? getDefault("tools.outputMaxColumns");
|
||||
}
|
||||
|
||||
/**
|
||||
* If the tool result text exceeds the spill threshold, save the full output
|
||||
* as a session artifact and replace the content with a head+tail (middle
|
||||
* elision) view plus an artifact reference. When `tools.artifactHeadBytes`
|
||||
* is 0, falls back to tail-only truncation. Skips when the tool already
|
||||
* saved its own artifact (e.g. bash/python via OutputSink).
|
||||
*/
|
||||
async function spillLargeResultToArtifact(
|
||||
result: AgentToolResult,
|
||||
@@ -466,7 +571,7 @@ async function spillLargeResultToArtifact(
|
||||
const sessionManager = context?.sessionManager;
|
||||
if (!sessionManager) return result;
|
||||
if (toolName === "read") return result;
|
||||
const { threshold, tailBytes, tailLines } = getSpillConfig(context?.settings);
|
||||
const { threshold, tailBytes, tailLines, headBytes } = getSpillConfig(context?.settings);
|
||||
|
||||
// Skip if tool already saved an artifact
|
||||
const existingMeta: OutputMeta | undefined = result.details?.meta;
|
||||
@@ -489,13 +594,21 @@ async function spillLargeResultToArtifact(
|
||||
const artifactId = await sessionManager.saveArtifact(fullText, toolName);
|
||||
if (!artifactId) return result;
|
||||
|
||||
// Truncate to tail
|
||||
const truncated = truncateTail(fullText, {
|
||||
maxBytes: tailBytes,
|
||||
maxLines: tailLines,
|
||||
});
|
||||
// Truncate: middle elision when a head budget is configured, otherwise tail-only.
|
||||
const useMiddle = headBytes > 0;
|
||||
const truncated = useMiddle
|
||||
? truncateMiddle(fullText, {
|
||||
maxBytes: headBytes + tailBytes,
|
||||
maxLines: tailLines * 2,
|
||||
maxHeadBytes: headBytes,
|
||||
maxHeadLines: tailLines,
|
||||
})
|
||||
: truncateTail(fullText, {
|
||||
maxBytes: tailBytes,
|
||||
maxLines: tailLines,
|
||||
});
|
||||
|
||||
// Replace text blocks with single tail-truncated block, keep images
|
||||
// Replace text blocks with single truncated block, keep images
|
||||
const newContent: (TextContent | ImageContent)[] = [];
|
||||
for (const block of result.content) {
|
||||
if (block.type !== "text") {
|
||||
@@ -507,18 +620,44 @@ async function spillLargeResultToArtifact(
|
||||
// Build truncation meta
|
||||
const outputLines = truncated.outputLines ?? truncated.totalLines;
|
||||
const outputBytes = truncated.outputBytes ?? truncated.totalBytes;
|
||||
const shownStart = truncated.totalLines - outputLines + 1;
|
||||
const truncationMeta: TruncationMeta = {
|
||||
direction: "tail",
|
||||
truncatedBy: truncated.truncatedBy ?? "bytes",
|
||||
totalLines: truncated.totalLines,
|
||||
totalBytes: truncated.totalBytes,
|
||||
outputLines,
|
||||
outputBytes,
|
||||
maxBytes: tailBytes,
|
||||
shownRange: { start: shownStart, end: truncated.totalLines },
|
||||
artifactId,
|
||||
};
|
||||
let truncationMeta: TruncationMeta;
|
||||
if (truncated.truncatedBy === "middle") {
|
||||
const elidedLines = truncated.elidedLines ?? Math.max(0, truncated.totalLines - outputLines);
|
||||
const elidedBytes = truncated.elidedBytes ?? Math.max(0, truncated.totalBytes - outputBytes);
|
||||
const keptLines = Math.max(0, outputLines - 1); // -1 for marker line
|
||||
const headLines = Math.ceil(keptLines / 2);
|
||||
const tailLineCount = keptLines - headLines;
|
||||
truncationMeta = {
|
||||
direction: "middle",
|
||||
truncatedBy: "middle",
|
||||
totalLines: truncated.totalLines,
|
||||
totalBytes: truncated.totalBytes,
|
||||
outputLines,
|
||||
outputBytes,
|
||||
maxBytes: headBytes + tailBytes,
|
||||
headRange: headLines > 0 ? { start: 1, end: headLines } : undefined,
|
||||
tailRange:
|
||||
tailLineCount > 0
|
||||
? { start: truncated.totalLines - tailLineCount + 1, end: truncated.totalLines }
|
||||
: undefined,
|
||||
elidedLines,
|
||||
elidedBytes,
|
||||
artifactId,
|
||||
};
|
||||
} else {
|
||||
const shownStart = truncated.totalLines - outputLines + 1;
|
||||
truncationMeta = {
|
||||
direction: "tail",
|
||||
truncatedBy: truncated.truncatedBy ?? "bytes",
|
||||
totalLines: truncated.totalLines,
|
||||
totalBytes: truncated.totalBytes,
|
||||
outputLines,
|
||||
outputBytes,
|
||||
maxBytes: tailBytes,
|
||||
shownRange: { start: shownStart, end: truncated.totalLines },
|
||||
artifactId,
|
||||
};
|
||||
}
|
||||
|
||||
const newMeta: OutputMeta = { ...(existingMeta ?? {}), truncation: truncationMeta };
|
||||
const newDetails = { ...(result.details ?? {}), meta: newMeta };
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
type TruncationResult,
|
||||
truncateHead,
|
||||
truncateHeadBytes,
|
||||
truncateLine,
|
||||
} from "../session/streaming-output";
|
||||
import { renderCodeCell, renderMarkdownCell, renderStatusLine } from "../tui";
|
||||
import { CachedOutputBlock } from "../tui/output-block";
|
||||
@@ -54,7 +55,12 @@ import {
|
||||
renderReadUrlResult,
|
||||
} from "./fetch";
|
||||
import { applyListLimit } from "./list-limit";
|
||||
import { formatFullOutputReference, formatStyledTruncationWarning, type OutputMeta } from "./output-meta";
|
||||
import {
|
||||
formatFullOutputReference,
|
||||
formatStyledTruncationWarning,
|
||||
type OutputMeta,
|
||||
resolveOutputMaxColumns,
|
||||
} from "./output-meta";
|
||||
import { expandPath, formatPathRelativeToCwd, resolveReadPath, splitPathAndSel } from "./path-utils";
|
||||
import { formatBytes, replaceTabs, shortenPath, wrapBrackets } from "./render-utils";
|
||||
import {
|
||||
@@ -81,6 +87,12 @@ const CONVERTIBLE_EXTENSIONS = new Set([".pdf", ".doc", ".docx", ".ppt", ".pptx"
|
||||
|
||||
const MAX_SUMMARY_BYTES = 2 * 1024 * 1024;
|
||||
const MAX_SUMMARY_LINES = 20_000;
|
||||
/**
|
||||
* Per-line column cap for file reads. Lines wider than the value of
|
||||
* `tools.outputMaxColumns` are ellipsis-truncated at display time; the file
|
||||
* on disk is unchanged. Shared with the streaming sink path so one setting
|
||||
* covers `bash`/`ssh`/`python`/`js eval` and `read` uniformly.
|
||||
*/
|
||||
const PROSE_SUMMARY_EXTENSIONS = new Set([".md", ".txt"]);
|
||||
// Remote mount path prefix (sshfs mounts) - skip fuzzy matching to avoid hangs
|
||||
const REMOTE_MOUNT_PREFIX = getRemoteDir() + path.sep;
|
||||
@@ -1309,6 +1321,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
||||
let content: Array<TextContent | ImageContent> | undefined;
|
||||
let details: ReadToolDetails = {};
|
||||
let sourcePath: string | undefined;
|
||||
let columnTruncated = 0;
|
||||
let truncationInfo:
|
||||
| { result: TruncationResult; options: { direction: "head"; startLine?: number; totalFileLines?: number } }
|
||||
| undefined;
|
||||
@@ -1498,6 +1511,22 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
||||
.done();
|
||||
}
|
||||
|
||||
// Per-line column cap. Skipped in raw mode so `:raw` always returns
|
||||
// verbatim bytes for paste-back-into-tool workflows. Total byte/line
|
||||
// counts in `truncation` keep reflecting the source, not the trimmed
|
||||
// view — column truncation surfaces separately via `.limits()`.
|
||||
const rawSelector = isRawSelector(parsed);
|
||||
const maxColumns = resolveOutputMaxColumns(this.session.settings);
|
||||
if (!rawSelector && maxColumns > 0) {
|
||||
for (let i = 0; i < collectedLines.length; i++) {
|
||||
const { text, wasTruncated } = truncateLine(collectedLines[i], maxColumns);
|
||||
if (wasTruncated) {
|
||||
collectedLines[i] = text;
|
||||
columnTruncated = maxColumns;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const selectedContent = collectedLines.join("\n");
|
||||
const userLimitedLines = collectedLines.length;
|
||||
|
||||
@@ -1522,9 +1551,8 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
||||
getFileReadCache(this.session).recordContiguous(absolutePath, startLineDisplay, collectedLines);
|
||||
}
|
||||
|
||||
const isRawMode = isRawSelector(parsed);
|
||||
const shouldAddHashLines = !isRawMode && displayMode.hashLines;
|
||||
const shouldAddLineNumbers = isRawMode ? false : shouldAddHashLines ? false : displayMode.lineNumbers;
|
||||
const shouldAddHashLines = !rawSelector && displayMode.hashLines;
|
||||
const shouldAddLineNumbers = rawSelector ? false : shouldAddHashLines ? false : displayMode.lineNumbers;
|
||||
let capturedDisplayContent: { text: string; startLine: number } | undefined;
|
||||
const formatText = (text: string, startNum: number): string => {
|
||||
capturedDisplayContent = { text, startLine: startNum };
|
||||
@@ -1636,6 +1664,9 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
||||
if (truncationInfo) {
|
||||
resultBuilder.truncation(truncationInfo.result, truncationInfo.options);
|
||||
}
|
||||
if (columnTruncated > 0) {
|
||||
resultBuilder.limits({ columnMax: columnTruncated });
|
||||
}
|
||||
return resultBuilder.done();
|
||||
}
|
||||
|
||||
|
||||
@@ -4,12 +4,14 @@ import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import {
|
||||
formatHeadTruncationNotice,
|
||||
formatMiddleElisionMarker,
|
||||
formatTailTruncationNotice,
|
||||
OutputSink,
|
||||
TailBuffer,
|
||||
truncateHead,
|
||||
truncateHeadBytes,
|
||||
truncateLine,
|
||||
truncateMiddle,
|
||||
truncateTail,
|
||||
truncateTailBytes,
|
||||
} from "../src/session/streaming-output";
|
||||
@@ -335,3 +337,149 @@ describe("truncation notice formatting", () => {
|
||||
).toBe("\n\n[Showing lines 100-100 of 500. Use :101 to continue]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("truncateMiddle", () => {
|
||||
test("returns content unchanged when within budget", () => {
|
||||
const result = truncateMiddle("a\nb\nc", { maxBytes: 100, maxLines: 10 });
|
||||
expect(result.truncated).toBeFalsy();
|
||||
expect(result.content).toBe("a\nb\nc");
|
||||
});
|
||||
|
||||
test("keeps head and tail with marker for byte-overflow content", () => {
|
||||
const lines = Array.from({ length: 12 }, (_, i) => `line-${i + 1}`).join("\n");
|
||||
const result = truncateMiddle(lines, {
|
||||
maxBytes: 24, // 12 bytes head + 12 bytes tail
|
||||
maxLines: 12,
|
||||
maxHeadBytes: 12,
|
||||
maxHeadLines: 3,
|
||||
});
|
||||
expect(result.truncated).toBe(true);
|
||||
expect(result.truncatedBy).toBe("middle");
|
||||
// Must contain first line and last line, plus the elision marker.
|
||||
expect(result.content.startsWith("line-1\n")).toBe(true);
|
||||
expect(result.content.endsWith("line-12")).toBe(true);
|
||||
expect(result.content).toContain("elided");
|
||||
expect(result.content).not.toContain("line-7"); // a middle line
|
||||
expect(result.elidedLines).toBeGreaterThan(0);
|
||||
expect(result.elidedBytes).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("falls back to tail-only when head budget cannot accept the first line", () => {
|
||||
const giantFirstLine = `${"x".repeat(200)}\nshort-2\nshort-3`;
|
||||
const result = truncateMiddle(giantFirstLine, {
|
||||
maxBytes: 40,
|
||||
maxLines: 10,
|
||||
maxHeadBytes: 8, // first line is 200 bytes — exceeds head budget
|
||||
maxHeadLines: 1,
|
||||
});
|
||||
expect(result.truncated).toBe(true);
|
||||
// Should not contain the elision marker; it's a regular tail truncation.
|
||||
expect(result.content).not.toContain("elided");
|
||||
});
|
||||
|
||||
test("formatMiddleElisionMarker pluralises and formats bytes", () => {
|
||||
expect(formatMiddleElisionMarker(1, 100)).toBe("[… 1 line elided (100B) …]");
|
||||
expect(formatMiddleElisionMarker(123, 4096)).toBe("[… 123 lines elided (4.0KB) …]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("OutputSink head-retain mode", () => {
|
||||
test("middle elision splices head, marker, and tail", async () => {
|
||||
const sink = new OutputSink({ spillThreshold: 6, headBytes: 6 });
|
||||
// Total 36 bytes: head ~6, tail ~6, middle ~24 elided.
|
||||
const lines = Array.from({ length: 12 }, (_, i) => `L${i}`).join("\n");
|
||||
await sink.push(lines);
|
||||
|
||||
const dumped = await sink.dump();
|
||||
expect(dumped.truncated).toBe(true);
|
||||
expect(dumped.elidedBytes ?? 0).toBeGreaterThan(0);
|
||||
expect(dumped.elidedLines ?? 0).toBeGreaterThan(0);
|
||||
expect(dumped.output.startsWith("L0\n")).toBe(true);
|
||||
expect(dumped.output.endsWith("L11")).toBe(true);
|
||||
expect(dumped.output).toContain("elided");
|
||||
expect(dumped.totalBytes).toBe(byteLength(lines));
|
||||
});
|
||||
|
||||
test("disabled (headBytes=0) preserves tail-only behavior", async () => {
|
||||
const sink = new OutputSink({ spillThreshold: 5, headBytes: 0 });
|
||||
await sink.push("abc");
|
||||
await sink.push("def");
|
||||
|
||||
const dumped = await sink.dump();
|
||||
expect(dumped.truncated).toBe(true);
|
||||
expect(dumped.output).toBe("bcdef");
|
||||
expect(dumped.elidedBytes).toBeUndefined();
|
||||
});
|
||||
|
||||
test("head fills cleanly across chunks without elision when total fits", async () => {
|
||||
const sink = new OutputSink({ spillThreshold: 50, headBytes: 4 });
|
||||
await sink.push("abcdefgh");
|
||||
const dumped = await sink.dump();
|
||||
expect(dumped.output).toBe("abcdefgh");
|
||||
expect(dumped.truncated).toBe(false);
|
||||
expect(dumped.elidedBytes).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("OutputSink maxColumns (per-line cap)", () => {
|
||||
test("truncates a single overlong line with an ellipsis and drops the rest", async () => {
|
||||
const sink = new OutputSink({ maxColumns: 8, spillThreshold: 1000 });
|
||||
await sink.push(`short\n${"x".repeat(50)}\nfooter`);
|
||||
|
||||
const dumped = await sink.dump();
|
||||
expect(dumped.truncated).toBe(true);
|
||||
expect(dumped.output).toContain("short\n");
|
||||
expect(dumped.output).toContain("\nfooter");
|
||||
expect(dumped.output).toContain("…");
|
||||
// The wide line shouldn't appear verbatim.
|
||||
expect(dumped.output).not.toContain("x".repeat(50));
|
||||
expect(dumped.columnTruncatedLines).toBe(1);
|
||||
expect(dumped.columnDroppedBytes ?? 0).toBeGreaterThan(0);
|
||||
// totalBytes still reflects the raw stream, not the post-cap view.
|
||||
expect(dumped.totalBytes).toBe(byteLength(`short\n${"x".repeat(50)}\nfooter`));
|
||||
});
|
||||
|
||||
test("persists per-line state across chunk boundaries", async () => {
|
||||
const sink = new OutputSink({ maxColumns: 4, spillThreshold: 1000 });
|
||||
await sink.push("ab"); // 2 bytes into the current line
|
||||
await sink.push("cd"); // 4 bytes total — still within cap
|
||||
await sink.push("efgh"); // tips over → ellipsis once, then drop rest
|
||||
await sink.push("ijkl\n");
|
||||
await sink.push("next");
|
||||
|
||||
const dumped = await sink.dump();
|
||||
const lines = dumped.output.split("\n");
|
||||
expect(lines[0]).toMatch(/^(abcd)?…$|^abcd…$/);
|
||||
expect(lines[1]).toBe("next");
|
||||
expect(dumped.columnTruncatedLines).toBe(1);
|
||||
});
|
||||
|
||||
test("disabled by default — maxColumns: 0 is a passthrough", async () => {
|
||||
const sink = new OutputSink({ spillThreshold: 4000 });
|
||||
const wide = "y".repeat(2000);
|
||||
await sink.push(wide);
|
||||
const dumped = await sink.dump();
|
||||
expect(dumped.output).toBe(wide);
|
||||
expect(dumped.columnTruncatedLines).toBeUndefined();
|
||||
expect(dumped.columnDroppedBytes).toBeUndefined();
|
||||
});
|
||||
|
||||
test("middle elision math subtracts column-dropped bytes", async () => {
|
||||
// Head + tail buffers are tiny; the wide middle line gets column-capped,
|
||||
// so its dropped bytes shouldn't be double-counted as "elided from middle".
|
||||
const sink = new OutputSink({
|
||||
maxColumns: 4,
|
||||
spillThreshold: 6,
|
||||
headBytes: 6,
|
||||
});
|
||||
const wideMiddle = "M".repeat(200);
|
||||
const input = `head\n${wideMiddle}\ntail`;
|
||||
await sink.push(input);
|
||||
const dumped = await sink.dump();
|
||||
const elided = dumped.elidedBytes ?? 0;
|
||||
const dropped = dumped.columnDroppedBytes ?? 0;
|
||||
expect(dropped).toBeGreaterThan(0);
|
||||
// elided + dropped + kept ≤ totalBytes (with a small slack for the marker/newlines).
|
||||
expect(elided + dropped).toBeLessThan(dumped.totalBytes);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -280,6 +280,33 @@ describe("Coding Agent Tools", () => {
|
||||
expect(result.details?.truncation).toBeUndefined();
|
||||
});
|
||||
|
||||
it("truncates lines wider than the read column cap, leaving narrow lines untouched", async () => {
|
||||
const wideLine = "x".repeat(1500);
|
||||
const testFile = path.join(testDir, "wide.txt");
|
||||
fs.writeFileSync(testFile, `header\n${wideLine}\nfooter`);
|
||||
|
||||
const result = await readTool.execute("test-call-column-truncate", { path: testFile });
|
||||
const output = getTextOutput(result);
|
||||
|
||||
expect(output).toContain("header");
|
||||
expect(output).toContain("footer");
|
||||
expect(output).not.toContain(wideLine); // verbatim wide line is gone
|
||||
expect(output).toContain("…"); // ellipsis marker
|
||||
expect(output).toContain("Some lines truncated to 768 chars");
|
||||
});
|
||||
|
||||
it("returns wide lines verbatim with the :raw selector", async () => {
|
||||
const wideLine = "y".repeat(1500);
|
||||
const testFile = path.join(testDir, "wide-raw.txt");
|
||||
fs.writeFileSync(testFile, `head\n${wideLine}\ntail`);
|
||||
|
||||
const result = await readTool.execute("test-call-column-raw", { path: `${testFile}:raw` });
|
||||
const output = getTextOutput(result);
|
||||
|
||||
expect(output).toContain(wideLine);
|
||||
expect(output).not.toContain("Some lines truncated to 768 chars");
|
||||
});
|
||||
|
||||
it("should read ipynb files as editable cell text", async () => {
|
||||
const notebookPath = path.join(testDir, "notebook.ipynb");
|
||||
const notebook = {
|
||||
|
||||
Reference in New Issue
Block a user