fix(coding-agent-tools): fixed OSC8 links to use resolved file paths from session context
- Fixed edit/read/search/ast-edit/ast-grep outputs to resolve OSC8 links from session cwd. - Fixed grouped-file output classification to honor headerBase and fileScope for parent path resolution. - Fixed read and write renderers to use resolved source/resolved paths as hyperlink targets.
This commit is contained in:
@@ -21,6 +21,7 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed tool-output file paths not being clickable OSC 8 `file://` hyperlinks in several renderers. `read` titles for plain text and image files (the common case) emitted no link at all because the renderer only linked when a `resolvedPath` was recorded — which the ordinary file/image read paths never set, keeping the absolute path only in `meta.source`; the renderer now falls back to that source path. `write` headers were never wrapped in a hyperlink and now link to the absolute path written (file, archive entry, SQLite, and conflict resolutions). `edit`/`apply_patch` headers wrapped the model-supplied (often cwd-relative) argument path, producing a root-anchored `file:///rel/path` URI; they now link the absolute `details.path` instead. Finally, `search`, `ast_grep`, and `ast_edit` produced doubled link targets (`/proj/src/src/file.ts`) for searches scoped to a subdirectory, because the renderer resolved the cwd-relative display paths against the scope directory rather than cwd — the scoped-search base is now the session cwd (with the scoped file's absolute path still seeding single-file body lines).
|
||||
- Fixed `omp dry-balance --bench` to recover from 401 token failures by re-minting the failing OAuth credential in place before switching accounts
|
||||
- Fixed the bash tool corrupting commands that embed multi-byte UTF-8 (e.g. `✓`/`×` inside a `grep -E` pattern) ahead of a trailing `| head`/`| tail`. The `bash.stripTrailingHeadTail` rewrite cut at char-offset positions reported by `brush-parser` while slicing the command by byte offset, so the trailing-pipe strip landed mid-pattern and dropped the closing quote — turning `… |✓|×|XCTAssert" | tail -80` into `… |✓|×-80` and making execution fail with `pi-natives:command: unterminated double quote`. Fixed in `pi_shell::fixup` (`@oh-my-pi/pi-natives`).
|
||||
- Fixed `omp dry-balance --bench` to recover from 401 token failures by re-minting the failing OAuth credential in place before switching accounts
|
||||
|
||||
@@ -185,10 +185,14 @@ function getOperationTitle(op: Operation | undefined): string {
|
||||
function formatEditPathDisplay(
|
||||
rawPath: string,
|
||||
uiTheme: Theme,
|
||||
options?: { rename?: string; firstChangedLine?: number },
|
||||
options?: { rename?: string; firstChangedLine?: number; linkPath?: string; renameLinkPath?: string },
|
||||
): string {
|
||||
// `rawPath`/`rename` are shown (cwd-relative) but the OSC 8 link targets the
|
||||
// absolute path when known — a relative `rawPath` would yield a `file:///rel`
|
||||
// URI that resolves against filesystem root instead of cwd.
|
||||
const linkTarget = options?.linkPath || rawPath;
|
||||
let pathDisplay = rawPath
|
||||
? fileHyperlink(rawPath, uiTheme.fg("accent", shortenPath(rawPath)))
|
||||
? fileHyperlink(linkTarget, uiTheme.fg("accent", shortenPath(rawPath)))
|
||||
: uiTheme.fg("toolOutput", "…");
|
||||
|
||||
if (options?.firstChangedLine) {
|
||||
@@ -196,7 +200,8 @@ function formatEditPathDisplay(
|
||||
}
|
||||
|
||||
if (options?.rename) {
|
||||
pathDisplay += ` ${uiTheme.fg("dim", "→")} ${fileHyperlink(options.rename, uiTheme.fg("accent", shortenPath(options.rename)))}`;
|
||||
const renameTarget = options.renameLinkPath || options.rename;
|
||||
pathDisplay += ` ${uiTheme.fg("dim", "→")} ${fileHyperlink(renameTarget, uiTheme.fg("accent", shortenPath(options.rename)))}`;
|
||||
}
|
||||
|
||||
return pathDisplay;
|
||||
@@ -205,7 +210,7 @@ function formatEditPathDisplay(
|
||||
function formatEditDescription(
|
||||
rawPath: string,
|
||||
uiTheme: Theme,
|
||||
options?: { rename?: string; firstChangedLine?: number },
|
||||
options?: { rename?: string; firstChangedLine?: number; linkPath?: string; renameLinkPath?: string },
|
||||
): { language: string; description: string } {
|
||||
const language = getLanguageFromPath(rawPath) ?? "text";
|
||||
const icon = uiTheme.fg("muted", uiTheme.getLangIcon(language));
|
||||
@@ -539,7 +544,8 @@ function renderSingleFileResult(
|
||||
const firstChangedLine =
|
||||
(editDiffPreview && "firstChangedLine" in editDiffPreview ? editDiffPreview.firstChangedLine : undefined) ||
|
||||
(details && !isError ? details.firstChangedLine : undefined);
|
||||
const { description } = formatEditDescription(rawPath, uiTheme, { rename, firstChangedLine });
|
||||
const linkPath = details && "path" in details ? details.path : undefined;
|
||||
const { description } = formatEditDescription(rawPath, uiTheme, { rename, firstChangedLine, linkPath });
|
||||
|
||||
// Change stats ride inline on the header bar next to the path.
|
||||
const previewDiff = editDiffPreview && !("error" in editDiffPreview) ? editDiffPreview.diff : undefined;
|
||||
|
||||
@@ -74,9 +74,6 @@ export class UiHelpers {
|
||||
* we update the previous status line instead of appending new ones to avoid log spam.
|
||||
*/
|
||||
showStatus(message: string, options?: { dim?: boolean }): void {
|
||||
if (this.ctx.isBackgrounded) {
|
||||
return;
|
||||
}
|
||||
const children = this.ctx.chatContainer.children;
|
||||
const last = children.length > 0 ? children[children.length - 1] : undefined;
|
||||
const secondLast = children.length > 1 ? children[children.length - 2] : undefined;
|
||||
@@ -534,9 +531,6 @@ export class UiHelpers {
|
||||
}
|
||||
|
||||
clearEditor(): void {
|
||||
if (this.ctx.isBackgrounded) {
|
||||
return;
|
||||
}
|
||||
this.ctx.editor.setText("");
|
||||
this.ctx.pendingImages = [];
|
||||
this.ctx.pendingImageLinks = [];
|
||||
@@ -545,18 +539,10 @@ export class UiHelpers {
|
||||
}
|
||||
|
||||
showError(errorMessage: string): void {
|
||||
if (this.ctx.isBackgrounded) {
|
||||
process.stderr.write(`Error: ${errorMessage}\n`);
|
||||
return;
|
||||
}
|
||||
this.ctx.present([new Spacer(1), new Text(theme.fg("error", `Error: ${errorMessage}`), 1, 0)]);
|
||||
}
|
||||
|
||||
showWarning(warningMessage: string): void {
|
||||
if (this.ctx.isBackgrounded) {
|
||||
process.stderr.write(`Warning: ${warningMessage}\n`);
|
||||
return;
|
||||
}
|
||||
this.ctx.present([new Spacer(1), new Text(theme.fg("warning", `Warning: ${warningMessage}`), 1, 0)]);
|
||||
}
|
||||
|
||||
|
||||
@@ -159,6 +159,9 @@ export interface AstEditToolDetails {
|
||||
/** Absolute base directory used during the edit. Used by the renderer to resolve
|
||||
* display-relative paths to absolute paths for OSC 8 hyperlinks. */
|
||||
searchPath?: string;
|
||||
/** Session cwd at edit time. Display header paths are cwd-relative, so the
|
||||
* renderer resolves them against this; `searchPath` is the scope target. */
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolDetails> {
|
||||
@@ -272,6 +275,7 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
|
||||
...(cappedParseErrors.length > 0 ? { parseErrors: cappedParseErrors, parseErrorsTotal } : {}),
|
||||
scopePath,
|
||||
searchPath: resolvedSearchPath,
|
||||
cwd: this.session.cwd,
|
||||
files: fileList,
|
||||
fileReplacements: [],
|
||||
};
|
||||
@@ -560,7 +564,7 @@ export const astEditToolRenderer = {
|
||||
const allLines = textContent.split("\n");
|
||||
// Resolve hyperlinks over the whole output so nested directory headers
|
||||
// reconstruct across the blank-line groups the tree list collapses by.
|
||||
const contexts = classifyGroupedLines(allLines, details?.searchPath);
|
||||
const contexts = classifyGroupedLines(allLines, details?.cwd ?? details?.searchPath, details?.searchPath);
|
||||
const styledLines = allLines.map((line, index) => {
|
||||
const ctx = contexts[index]!;
|
||||
// Swap the inner code-frame gutter `│` for a space so it does not nest a
|
||||
|
||||
@@ -117,6 +117,9 @@ export interface AstGrepToolDetails {
|
||||
/** Absolute base directory used during search. Used by the renderer to resolve
|
||||
* display-relative paths to absolute paths for OSC 8 hyperlinks. */
|
||||
searchPath?: string;
|
||||
/** Session cwd at search time. Display header/match paths are cwd-relative, so
|
||||
* the renderer resolves them against this; `searchPath` is the scope target. */
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolDetails> {
|
||||
@@ -206,6 +209,7 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
|
||||
...(cappedParseErrors.length > 0 ? { parseErrors: cappedParseErrors, parseErrorsTotal } : {}),
|
||||
scopePath,
|
||||
searchPath: resolvedSearchPath,
|
||||
cwd: this.session.cwd,
|
||||
files: fileList,
|
||||
fileMatches: [],
|
||||
};
|
||||
@@ -395,7 +399,7 @@ export const astGrepToolRenderer = {
|
||||
const allLines = textContent.split("\n");
|
||||
// Resolve hyperlinks over the whole output so nested directory headers
|
||||
// reconstruct across the blank-line groups the tree list collapses by.
|
||||
const contexts = classifyGroupedLines(allLines, details?.searchPath);
|
||||
const contexts = classifyGroupedLines(allLines, details?.cwd ?? details?.searchPath, details?.searchPath);
|
||||
const styledLines = allLines.map((line, index) => {
|
||||
const ctx = contexts[index]!;
|
||||
if (ctx.kind === "dir") {
|
||||
|
||||
@@ -237,12 +237,23 @@ function resolveGroupedPath(parent: string | undefined, name: string): string |
|
||||
* each header and body line can be linked back to its absolute filesystem path.
|
||||
* Reconstruction is stack-based (not per-blank-group) so nested directory headers
|
||||
* resolve correctly across the whole output.
|
||||
*
|
||||
* `headerBase` is the directory the displayed (folded) header paths are relative
|
||||
* to — for grep/ast tools that is the session cwd, since display paths are
|
||||
* formatted relative to cwd regardless of the (sub)directory the search was
|
||||
* scoped to. `fileScope` is the initial owning file for body lines that appear
|
||||
* before any header (single-file scopes have no `#` headers); it defaults to
|
||||
* `headerBase` and should be passed the scoped file's absolute path.
|
||||
*/
|
||||
export function classifyGroupedLines(lines: readonly string[], searchBase: string | undefined): GroupedLineContext[] {
|
||||
export function classifyGroupedLines(
|
||||
lines: readonly string[],
|
||||
headerBase: string | undefined,
|
||||
fileScope: string | undefined = headerBase,
|
||||
): GroupedLineContext[] {
|
||||
const result: GroupedLineContext[] = [];
|
||||
const dirAtDepth = new Map<number, string>();
|
||||
// Body lines before any header (single-file scopes) link to the search root.
|
||||
let currentFile = searchBase;
|
||||
// Body lines before any header (single-file scopes) link to the scoped file.
|
||||
let currentFile = fileScope;
|
||||
|
||||
const clearDeeper = (depth: number) => {
|
||||
for (const key of dirAtDepth.keys()) {
|
||||
@@ -264,7 +275,7 @@ export function classifyGroupedLines(lines: readonly string[], searchBase: strin
|
||||
result.push({ kind: "file", depth, isUrl: true });
|
||||
continue;
|
||||
}
|
||||
const parent = depth > 1 ? dirAtDepth.get(depth - 1) : searchBase;
|
||||
const parent = depth > 1 ? dirAtDepth.get(depth - 1) : headerBase;
|
||||
if (rest.endsWith("/")) {
|
||||
const name = rest.slice(0, -1).replace(HEADER_SUFFIX_RE, "");
|
||||
const abs = resolveGroupedPath(parent, name);
|
||||
|
||||
@@ -2337,10 +2337,20 @@ function firstReadSelectorLine(sel: string | undefined): number | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
/** Absolute fs path the read result actually resolved to, used as the OSC 8 link
|
||||
* target when the structured `resolvedPath` isn't set (the common plain-file and
|
||||
* image reads only record the path in `meta.source`). URL/internal sources are
|
||||
* not fs paths, so only `type: "path"` qualifies. */
|
||||
function readSourceFsPath(details: ReadToolDetails | undefined): string | undefined {
|
||||
const source = details?.meta?.source;
|
||||
return source?.type === "path" ? source.value : undefined;
|
||||
}
|
||||
|
||||
function formatReadPathLink(
|
||||
rawPath: string,
|
||||
options: {
|
||||
resolvedPath?: string;
|
||||
sourcePath?: string;
|
||||
suffixResolution?: { from: string; to: string };
|
||||
offset?: number;
|
||||
fallbackLabel?: string;
|
||||
@@ -2352,7 +2362,7 @@ function formatReadPathLink(
|
||||
const plainDisplayPath = options.suffixResolution
|
||||
? shortenPath(options.suffixResolution.to)
|
||||
: shortenPath(basePath || options.resolvedPath || options.fallbackLabel || rawPath);
|
||||
const target = options.resolvedPath ?? tryResolveInternalUrlSync(basePath);
|
||||
const target = options.resolvedPath ?? options.sourcePath ?? tryResolveInternalUrlSync(basePath);
|
||||
const line = firstReadSelectorLine(split.sel) ?? options.offset;
|
||||
const linkOptions = line !== undefined ? { line } : undefined;
|
||||
const displayPath = target ? fileHyperlink(target, plainDisplayPath, linkOptions) : plainDisplayPath;
|
||||
@@ -2403,7 +2413,9 @@ export const readToolRenderer = {
|
||||
const rawErrorText = result.content?.find(c => c.type === "text")?.text ?? "";
|
||||
const errorText = (rawErrorText || "Unknown error").replace(/^Error:\s*/, "");
|
||||
const rawPath = args?.file_path || args?.path || "";
|
||||
const filePath = formatReadPathLink(rawPath, { offset: args?.offset }) || shortenPath(rawPath);
|
||||
const filePath =
|
||||
formatReadPathLink(rawPath, { offset: args?.offset, sourcePath: readSourceFsPath(result.details) }) ||
|
||||
shortenPath(rawPath);
|
||||
let title = filePath ? `Read ${filePath}` : "Read";
|
||||
if (args?.offset !== undefined || args?.limit !== undefined) {
|
||||
const startLine = args.offset ?? 1;
|
||||
@@ -2454,6 +2466,7 @@ export const readToolRenderer = {
|
||||
const suffix = details?.suffixResolution;
|
||||
const displayPath = formatReadPathLink(rawPath, {
|
||||
resolvedPath: details?.resolvedPath,
|
||||
sourcePath: readSourceFsPath(details),
|
||||
suffixResolution: suffix,
|
||||
fallbackLabel: "image",
|
||||
});
|
||||
@@ -2486,12 +2499,13 @@ export const readToolRenderer = {
|
||||
}
|
||||
|
||||
const suffix = details?.suffixResolution;
|
||||
// resolvedPath is the absolute fs path for fs-backed reads (regular files plus
|
||||
// local:// / memory:// / skill:// / artifact:// resources). Fall back to a sync
|
||||
// resolver for fs-backed internal URLs so the title is clickable even before the
|
||||
// result lands or if the handler didn't populate resolvedPath.
|
||||
// resolvedPath is the absolute fs path when a read resolved/corrected the
|
||||
// input (suffix match, internal URL, archive/sqlite/notebook); plain file
|
||||
// reads only record the absolute path in meta.source, so fall back to that
|
||||
// (and then to a sync internal-URL resolver) to keep the title clickable.
|
||||
const displayPath = formatReadPathLink(rawPath, {
|
||||
resolvedPath: details?.resolvedPath,
|
||||
sourcePath: readSourceFsPath(details),
|
||||
suffixResolution: suffix,
|
||||
offset: args?.offset,
|
||||
});
|
||||
|
||||
@@ -621,6 +621,10 @@ export interface SearchToolDetails {
|
||||
/** Absolute base directory used during search. Used by the renderer to resolve
|
||||
* display-relative paths to absolute paths for OSC 8 hyperlinks. */
|
||||
searchPath?: string;
|
||||
/** Session cwd at search time. The renderer resolves the display-relative
|
||||
* (cwd-relative) header/match paths against this for OSC 8 hyperlinks;
|
||||
* `searchPath` is the scope label target, not the display-path base. */
|
||||
cwd?: string;
|
||||
/** User-supplied paths whose base directory was missing on disk. The tool
|
||||
* skipped these and continued with the surviving entries; surfaced as a
|
||||
* non-fatal warning in the renderer and in the model-facing text. */
|
||||
@@ -1003,6 +1007,7 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
|
||||
const details: SearchToolDetails = {
|
||||
scopePath,
|
||||
searchPath,
|
||||
cwd: this.session.cwd,
|
||||
matchCount: 0,
|
||||
fileCount: 0,
|
||||
files: [],
|
||||
@@ -1129,6 +1134,7 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
|
||||
const details: SearchToolDetails = {
|
||||
scopePath,
|
||||
searchPath,
|
||||
cwd: this.session.cwd,
|
||||
matchCount: selectedMatches.length,
|
||||
fileCount: fileList.length,
|
||||
files: fileList,
|
||||
@@ -1215,10 +1221,11 @@ const URL_HEADER_PREFIX_RE = /^#+\s+/;
|
||||
|
||||
function renderSearchDisplayLines(
|
||||
lines: readonly string[],
|
||||
searchBase: string | undefined,
|
||||
headerBase: string | undefined,
|
||||
fileScope: string | undefined,
|
||||
uiTheme: Theme,
|
||||
): RenderedSearchLine[] {
|
||||
const contexts = classifyGroupedLines(lines, searchBase);
|
||||
const contexts = classifyGroupedLines(lines, headerBase, fileScope);
|
||||
// `classifyGroupedLines` can't resolve internal URLs (TUI-only), so track the
|
||||
// resolved URL target here and use it for the body lines that follow.
|
||||
let urlFile: string | undefined;
|
||||
@@ -1439,7 +1446,15 @@ export const searchToolRenderer = {
|
||||
const allLines = textContent.split("\n");
|
||||
// Resolve hyperlinks once over the whole output so a nested directory stack
|
||||
// reconstructs correctly across blank-line group boundaries.
|
||||
const renderedLines = renderSearchDisplayLines(allLines, details?.searchPath, uiTheme);
|
||||
// Header/match display paths are cwd-relative, so resolve them against cwd
|
||||
// (falling back to searchPath for legacy results that predate `cwd`); the
|
||||
// scoped file's absolute path seeds body lines in single-file searches.
|
||||
const renderedLines = renderSearchDisplayLines(
|
||||
allLines,
|
||||
details?.cwd ?? details?.searchPath,
|
||||
details?.searchPath,
|
||||
uiTheme,
|
||||
);
|
||||
const matchGroups = groupLineIndicesByBlank(allLines).map(indices => indices.map(i => renderedLines[i]!));
|
||||
|
||||
const extraLines: string[] = [];
|
||||
|
||||
@@ -18,7 +18,7 @@ import { getDiagnosticsLedger } from "../lsp/diagnostics-ledger";
|
||||
import { getLanguageFromPath, highlightCode, type Theme } from "../modes/theme/theme";
|
||||
import writeDescription from "../prompts/tools/write.md" with { type: "text" };
|
||||
import type { ToolSession } from "../sdk";
|
||||
import { framedBlock, renderStatusLine } from "../tui";
|
||||
import { fileHyperlink, framedBlock, renderStatusLine } from "../tui";
|
||||
import { resolveFileDisplayMode } from "../utils/file-display-mode";
|
||||
import { truncateForPrompt } from "./approval";
|
||||
import { parseArchivePathCandidates } from "./archive-reader";
|
||||
@@ -77,6 +77,9 @@ export interface WriteToolDetails {
|
||||
meta?: OutputMeta;
|
||||
/** Set when the file was auto-chmod'd because content begins with a `#!` shebang. */
|
||||
madeExecutable?: boolean;
|
||||
/** Absolute filesystem path the write resolved to. Used by the renderer to wrap
|
||||
* the (possibly cwd-relative) header path in an OSC 8 `file://` hyperlink. */
|
||||
resolvedPath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -416,7 +419,7 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
|
||||
}`;
|
||||
return {
|
||||
content: [{ type: "text", text: `Successfully wrote ${content.length} bytes to ${outputPath}` }],
|
||||
details: {},
|
||||
details: { resolvedPath: resolvedArchivePath.absolutePath },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -532,7 +535,10 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
|
||||
}
|
||||
|
||||
invalidateFsScanAfterWrite(resolvedSqlitePath.absolutePath);
|
||||
return toolResult<WriteToolDetails>({}).text(resultText).sourcePath(resolvedSqlitePath.absolutePath).done();
|
||||
return toolResult<WriteToolDetails>({ resolvedPath: resolvedSqlitePath.absolutePath })
|
||||
.text(resultText)
|
||||
.sourcePath(resolvedSqlitePath.absolutePath)
|
||||
.done();
|
||||
} catch (error) {
|
||||
if (isEnoent(error)) {
|
||||
throw new ToolError(`SQLite database '${displayPath}' not found`);
|
||||
@@ -593,12 +599,13 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
|
||||
if (!diagnostics) {
|
||||
return {
|
||||
content: [{ type: "text", text: resultText }],
|
||||
details: {},
|
||||
details: { resolvedPath: absolutePath },
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [{ type: "text", text: resultText }],
|
||||
details: {
|
||||
resolvedPath: absolutePath,
|
||||
diagnostics,
|
||||
meta: outputMeta()
|
||||
.diagnostics(diagnostics.summary, diagnostics.messages ?? [])
|
||||
@@ -871,7 +878,10 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
|
||||
if (stripped) {
|
||||
resultText += `\nNote: auto-stripped hashline display prefixes from content before writing.`;
|
||||
}
|
||||
return { content: [{ type: "text", text: resultText }], details: {} };
|
||||
return {
|
||||
content: [{ type: "text", text: resultText }],
|
||||
details: { resolvedPath: absolutePath },
|
||||
};
|
||||
}
|
||||
|
||||
const diagnostics = await this.#writethrough(absolutePath, cleanContent, signal, undefined, batchRequest);
|
||||
@@ -888,13 +898,14 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
|
||||
if (!diagnostics) {
|
||||
return {
|
||||
content: [{ type: "text", text: resultText }],
|
||||
details: { madeExecutable: madeExecutable || undefined },
|
||||
details: { resolvedPath: absolutePath, madeExecutable: madeExecutable || undefined },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: resultText }],
|
||||
details: {
|
||||
resolvedPath: absolutePath,
|
||||
diagnostics,
|
||||
madeExecutable: madeExecutable || undefined,
|
||||
meta: outputMeta()
|
||||
@@ -1050,7 +1061,12 @@ export const writeToolRenderer = {
|
||||
const fileContent = args?.content || "";
|
||||
const lang = getLanguageFromPath(rawPath);
|
||||
const langIcon = uiTheme.fg("muted", uiTheme.getLangIcon(lang));
|
||||
const pathDisplay = filePath ? uiTheme.fg("accent", filePath) : uiTheme.fg("toolOutput", "…");
|
||||
// The header shows the cwd-relative path but links to the absolute path the
|
||||
// write resolved to (args.path may be relative, which would yield a broken
|
||||
// `file://` URI). Falls back to plain text when the result lacks a path.
|
||||
const linkTarget = result.details?.resolvedPath;
|
||||
const styledPath = filePath ? uiTheme.fg("accent", filePath) : uiTheme.fg("toolOutput", "…");
|
||||
const pathDisplay = filePath && linkTarget ? fileHyperlink(linkTarget, styledPath) : styledPath;
|
||||
|
||||
if (result.isError) {
|
||||
const errorText = result.content?.find(c => c.type === "text")?.text ?? "";
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import { describe, it } from "bun:test";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { resetSettingsForTest, Settings, settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||
import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
|
||||
import { ReadTool } from "@oh-my-pi/pi-coding-agent/tools/read";
|
||||
import { getThemeByName, initTheme } from "../../src/modes/theme/theme";
|
||||
import { readToolRenderer } from "../../src/tools/read";
|
||||
|
||||
const TINY_PNG_BASE64 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==";
|
||||
|
||||
function extractLinkUris(text: string): string[] {
|
||||
return [...text.matchAll(/\x1b\]8;[^;]*;([^\x1b]+)\x1b\\/g)].map(match => match[1]!);
|
||||
}
|
||||
|
||||
function createTestToolSession(cwd: string): ToolSession {
|
||||
return {
|
||||
cwd,
|
||||
hasUI: false,
|
||||
getSessionFile: () => null,
|
||||
getSessionSpawns: () => "*",
|
||||
settings: Settings.isolated(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("probe real read render", () => {
|
||||
it("text and image link emission", async () => {
|
||||
await initTheme();
|
||||
resetSettingsForTest();
|
||||
await Settings.init({ inMemory: true });
|
||||
settings.override("tui.hyperlinks", "always");
|
||||
const theme = await getThemeByName("dark");
|
||||
|
||||
const testDir = fs.mkdtempSync(path.join(os.tmpdir(), "probe-"));
|
||||
const textPath = path.join(testDir, "v4-task.txt");
|
||||
fs.writeFileSync(textPath, "hello world\nsecond line\n");
|
||||
const imgPath = path.join(testDir, "v4-task.png");
|
||||
fs.writeFileSync(imgPath, Buffer.from(TINY_PNG_BASE64, "base64"));
|
||||
|
||||
const tool = new ReadTool(createTestToolSession(testDir));
|
||||
const textRes = await tool.execute("t", { path: textPath });
|
||||
const imgRes = await tool.execute("i", { path: imgPath });
|
||||
|
||||
const textComp = readToolRenderer.renderResult(
|
||||
{ content: textRes.content, details: textRes.details, isError: textRes.isError },
|
||||
{ expanded: false, isPartial: false },
|
||||
theme!,
|
||||
{ path: textPath },
|
||||
);
|
||||
const imgComp = readToolRenderer.renderResult(
|
||||
{ content: imgRes.content, details: imgRes.details, isError: imgRes.isError },
|
||||
{ expanded: false, isPartial: false },
|
||||
theme!,
|
||||
{ path: imgPath },
|
||||
);
|
||||
console.log("TEXT URIS:", JSON.stringify(extractLinkUris(textComp.render(200).join("\n"))));
|
||||
console.log("IMAGE URIS:", JSON.stringify(extractLinkUris(imgComp.render(200).join("\n"))));
|
||||
|
||||
settings.clearOverride("tui.hyperlinks");
|
||||
resetSettingsForTest();
|
||||
fs.rmSync(testDir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { resetSettingsForTest, Settings, settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||
import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
|
||||
import { astGrepToolRenderer } from "@oh-my-pi/pi-coding-agent/tools/ast-grep";
|
||||
import { ReadTool, readToolRenderer } from "@oh-my-pi/pi-coding-agent/tools/read";
|
||||
import { searchToolRenderer } from "@oh-my-pi/pi-coding-agent/tools/search";
|
||||
import { WriteTool, writeToolRenderer } from "@oh-my-pi/pi-coding-agent/tools/write";
|
||||
import { editToolRenderer } from "@oh-my-pi/pi-coding-agent/edit/renderer";
|
||||
import { getThemeByName, initTheme } from "../../src/modes/theme/theme";
|
||||
|
||||
// 1x1 PNG so the read tool takes its image branch.
|
||||
const TINY_PNG_BASE64 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==";
|
||||
|
||||
function extractLinkUris(text: string): string[] {
|
||||
return [...text.matchAll(/\x1b\]8;[^;]*;([^\x1b]+)\x1b\\/g)].map(match => match[1]!);
|
||||
}
|
||||
|
||||
function createTestToolSession(cwd: string): ToolSession {
|
||||
return {
|
||||
cwd,
|
||||
hasUI: false,
|
||||
getSessionFile: () => null,
|
||||
getSessionSpawns: () => "*",
|
||||
settings: Settings.isolated(),
|
||||
};
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await initTheme(false);
|
||||
resetSettingsForTest();
|
||||
await Settings.init({ inMemory: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
settings.clearOverride("tui.hyperlinks");
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
resetSettingsForTest();
|
||||
});
|
||||
|
||||
describe("tool output OSC 8 file:// hyperlinks", () => {
|
||||
it("links plain text and image read titles to the resolved filesystem path", async () => {
|
||||
settings.override("tui.hyperlinks", "always");
|
||||
const theme = (await getThemeByName("dark"))!;
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "omp-link-read-"));
|
||||
try {
|
||||
const textPath = path.join(dir, "task.txt");
|
||||
fs.writeFileSync(textPath, "hello\nworld\n");
|
||||
const imgPath = path.join(dir, "task.png");
|
||||
fs.writeFileSync(imgPath, Buffer.from(TINY_PNG_BASE64, "base64"));
|
||||
|
||||
const tool = new ReadTool(createTestToolSession(dir));
|
||||
const textRes = await tool.execute("t", { path: textPath });
|
||||
const imgRes = await tool.execute("i", { path: imgPath });
|
||||
|
||||
const textRender = readToolRenderer
|
||||
.renderResult(
|
||||
{ content: textRes.content, details: textRes.details, isError: textRes.isError },
|
||||
{ expanded: false, isPartial: false },
|
||||
theme,
|
||||
{ path: textPath },
|
||||
)
|
||||
.render(200)
|
||||
.join("\n");
|
||||
const imgRender = readToolRenderer
|
||||
.renderResult(
|
||||
{ content: imgRes.content, details: imgRes.details, isError: imgRes.isError },
|
||||
{ expanded: false, isPartial: false },
|
||||
theme,
|
||||
{ path: imgPath },
|
||||
)
|
||||
.render(200)
|
||||
.join("\n");
|
||||
|
||||
expect(extractLinkUris(textRender)).toContain(`file://${textPath}`);
|
||||
expect(extractLinkUris(imgRender)).toContain(`file://${imgPath}`);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("links the write header to the absolute path it wrote", async () => {
|
||||
settings.override("tui.hyperlinks", "always");
|
||||
const theme = (await getThemeByName("dark"))!;
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "omp-link-write-"));
|
||||
try {
|
||||
const filePath = path.join(dir, "out.ts");
|
||||
const tool = new WriteTool(createTestToolSession(dir));
|
||||
const res = await tool.execute("w", { path: filePath, content: "export const x = 1;\n" });
|
||||
const rendered = writeToolRenderer
|
||||
.renderResult(
|
||||
{ content: res.content, details: res.details, isError: res.isError },
|
||||
{ expanded: false, isPartial: false },
|
||||
theme,
|
||||
{ path: filePath },
|
||||
)
|
||||
.render(200)
|
||||
.join("\n");
|
||||
expect(extractLinkUris(rendered)).toContain(`file://${filePath}`);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves scoped search links against cwd, not the (sub)scope path", async () => {
|
||||
settings.override("tui.hyperlinks", "always");
|
||||
const theme = (await getThemeByName("dark"))!;
|
||||
// Scoped search: scope dir (`searchPath`) is below cwd, and the grouped
|
||||
// display paths are cwd-relative. Resolving against searchPath would double
|
||||
// the `src` prefix (`/proj/src/src/...`).
|
||||
const result = {
|
||||
content: [{ type: "text", text: "" }],
|
||||
details: {
|
||||
matchCount: 1,
|
||||
fileCount: 1,
|
||||
cwd: "/tmp/omp-project",
|
||||
searchPath: "/tmp/omp-project/src",
|
||||
scopePath: "src",
|
||||
displayContent: ["# src/", "## interactive-mode.ts#abcd", "*12│const needle = true;"].join("\n"),
|
||||
},
|
||||
};
|
||||
const rendered = searchToolRenderer
|
||||
.renderResult(result as never, { expanded: true, isPartial: false }, theme, { pattern: "needle" })
|
||||
.render(240)
|
||||
.join("\n");
|
||||
const uris = extractLinkUris(rendered);
|
||||
expect(uris).toContain("file:///tmp/omp-project/src/interactive-mode.ts");
|
||||
expect(uris).toContain("file:///tmp/omp-project/src/interactive-mode.ts?line=12");
|
||||
expect(uris.some(uri => uri.includes("/src/src/"))).toBe(false);
|
||||
});
|
||||
|
||||
it("resolves scoped ast-grep links against cwd, not the (sub)scope path", async () => {
|
||||
settings.override("tui.hyperlinks", "always");
|
||||
const theme = (await getThemeByName("dark"))!;
|
||||
const result = {
|
||||
content: [{ type: "text", text: "" }],
|
||||
details: {
|
||||
matchCount: 1,
|
||||
fileCount: 1,
|
||||
filesSearched: 1,
|
||||
limitReached: false,
|
||||
cwd: "/tmp/omp-project",
|
||||
searchPath: "/tmp/omp-project/src",
|
||||
scopePath: "src",
|
||||
displayContent: ["# src/", "## interactive-mode.ts", " *12│const needle = true;"].join("\n"),
|
||||
},
|
||||
};
|
||||
const rendered = astGrepToolRenderer
|
||||
.renderResult(result as never, { expanded: true, isPartial: false }, theme, { pat: "needle" })
|
||||
.render(240)
|
||||
.join("\n");
|
||||
const uris = extractLinkUris(rendered);
|
||||
expect(uris).toContain("file:///tmp/omp-project/src/interactive-mode.ts");
|
||||
expect(uris.some(uri => uri.includes("/src/src/"))).toBe(false);
|
||||
});
|
||||
|
||||
it("links the edit header to the absolute details.path even when the arg path is relative", async () => {
|
||||
settings.override("tui.hyperlinks", "always");
|
||||
const theme = (await getThemeByName("dark"))!;
|
||||
const rendered = editToolRenderer
|
||||
.renderResult(
|
||||
{
|
||||
content: [{ type: "text", text: "Updated src/a.ts" }],
|
||||
details: { diff: "+1|// x", op: "update", path: "/tmp/omp-project/src/a.ts" },
|
||||
},
|
||||
{ expanded: false, isPartial: false, renderContext: { editMode: "hashline" } },
|
||||
theme,
|
||||
{ path: "src/a.ts" },
|
||||
)
|
||||
.render(200)
|
||||
.join("\n");
|
||||
const uris = extractLinkUris(rendered);
|
||||
expect(uris).toContain("file:///tmp/omp-project/src/a.ts");
|
||||
// A relative arg path must not leak into a root-anchored `file:///src/a.ts`.
|
||||
expect(uris).not.toContain("file:///src/a.ts");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user