refactor(coding-agent): extracted shared render-utils to eliminate duplication

- Added shared render-utils module with standardized formatting functions for truncation, bytes, tokens, duration, and tree rendering.
- Extracted resolveOmpCommand() helper to centralize cross-platform subprocess command resolution.
- Consolidated duplicate utility functions across grep, find, ls, notebook, ask, output, exa, lsp, and web search renderers.
- Updated web search tool to support 'auto' as explicit provider option for auto-detection.
This commit is contained in:
can1357
2026-01-05 07:35:35 +01:00
parent 8a5983be2d
commit 0315cef75d
17 changed files with 858 additions and 619 deletions
+1
View File
@@ -8,6 +8,7 @@
],
"scripts": {
"install:dev": "bun install && bun --cwd=packages/coding-agent link && bun --cwd=packages/ai link && bun --cwd=packages/mom link",
"agent": "bun --cwd=packages/coding-agent src/cli.ts",
"build": "bun --cwd=packages/web-ui run build:css",
"dev": "bun --cwd=packages/web-ui run dev",
"check": "biome check --write . && bun --cwd=packages/coding-agent run check && bun --cwd=packages/web-ui run check",
+4 -1
View File
@@ -1,15 +1,18 @@
# Changelog
## [Unreleased]
### Added
- Added shared render utilities module with standardized formatting functions for truncation, byte/token/duration display, and tree rendering
- Added `resolveOmpCommand()` helper to resolve subprocess command from environment or entry point
- Added `cycleRoleModels()` method to cycle through configured role-based models in a fixed order with deduplication
- Added language-specific file icons to LSP diagnostics output showing file locations
- Added language-specific file icon to edit tool header display
### Changed
- Updated web search tool to support `auto` as explicit provider option for auto-detection
- Standardized tool result rendering across grep, find, ls, notebook, ask, output, and web search tools with consistent tree formatting and expand hints
- Updated grep and find tool output to display language-specific icons for files and folder icons for directories
- Updated file listing to display language-specific icons based on file extension instead of generic file icons
@@ -18,7 +18,6 @@ import type { AssistantMessage, ImageContent, Message, Model, TextContent, Usage
import { isContextOverflow, modelsAreEqual, supportsXhigh } from "@oh-my-pi/pi-ai";
import type { Rule } from "../capability/rule";
import { getAuthPath } from "../config";
import { parseModelString } from "./model-resolver";
import { type BashResult, executeBash as executeBashCommand } from "./bash-executor";
import {
type CompactionResult,
@@ -46,6 +45,7 @@ import type {
import { logger } from "./logger";
import type { BashExecutionMessage, HookMessage } from "./messages";
import type { ModelRegistry } from "./model-registry";
import { parseModelString } from "./model-resolver";
import type { BranchSummaryEntry, CompactionEntry, NewSessionOptions, SessionManager } from "./session-manager";
import type { SettingsManager, SkillsSettings } from "./settings-manager";
import { expandSlashCommand, type FileSlashCommand, parseCommandArgs } from "./slash-commands";
@@ -921,7 +921,7 @@ export class AgentSession {
for (const role of roleOrder) {
const roleModelStr =
role === "default"
? this.settingsManager.getModelRole("default") ?? `${currentModel.provider}/${currentModel.id}`
? (this.settingsManager.getModelRole("default") ?? `${currentModel.provider}/${currentModel.id}`)
: this.settingsManager.getModelRole(role);
if (!roleModelStr) continue;
+175 -130
View File
@@ -9,29 +9,37 @@ import { Text } from "@oh-my-pi/pi-tui";
import type { Theme } from "../../../modes/interactive/theme/theme";
import type { RenderResultOptions } from "../../custom-tools/types";
import { logger } from "../../logger";
import {
formatCount,
formatExpandHint,
formatMoreItems,
getDomain,
getPreviewLines,
getStyledStatusIcon,
PREVIEW_LIMITS,
TRUNCATE_LENGTHS,
truncate,
} from "../render-utils";
import type { ExaRenderDetails } from "./types";
/** Truncate text to max length with ellipsis */
function truncate(text: string, maxLen: number, ellipsis: string): string {
if (text.length <= maxLen) return text;
const sliceLen = Math.max(0, maxLen - ellipsis.length);
return `${text.slice(0, sliceLen)}${ellipsis}`;
const COLLAPSED_PREVIEW_LINES = PREVIEW_LIMITS.COLLAPSED_LINES;
const COLLAPSED_PREVIEW_LINE_LEN = TRUNCATE_LENGTHS.LONG;
const EXPANDED_TEXT_LINES = 5;
const EXPANDED_TEXT_LINE_LEN = 90;
const MAX_TITLE_LEN = TRUNCATE_LENGTHS.TITLE;
const MAX_HIGHLIGHT_LEN = TRUNCATE_LENGTHS.CONTENT;
function renderErrorMessage(message: string, theme: Theme): Text {
const clean = message.replace(/^Error:\s*/, "").trim();
return new Text(
`${getStyledStatusIcon("error", theme)} ${theme.fg("error", `Error: ${clean || "Unknown error"}`)}`,
0,
0,
);
}
/** Extract domain from URL */
function getDomain(url: string): string {
try {
const u = new URL(url);
return u.hostname.replace(/^www\./, "");
} catch {
return url;
}
}
/** Get first N lines of text as preview */
function getPreviewLines(text: string, maxLines: number, maxLineLen: number, ellipsis: string): string[] {
const lines = text.split("\n").filter((l) => l.trim());
return lines.slice(0, maxLines).map((l) => truncate(l.trim(), maxLineLen, ellipsis));
function renderEmptyMessage(message: string, theme: Theme): Text {
return new Text(`${getStyledStatusIcon("warning", theme)} ${theme.fg("muted", message)}`, 0, 0);
}
/** Render Exa result with tree-based layout */
@@ -43,26 +51,42 @@ export function renderExaResult(
const { expanded } = options;
const details = result.details;
// Handle error case
if (details?.error) {
logger.error("Exa render error", { error: details.error, toolName: details.toolName });
return new Text(uiTheme.fg("error", `Error: ${details.error}`), 0, 0);
return renderErrorMessage(details.error, uiTheme);
}
const response = details?.response;
if (!response) {
// Non-search response: show raw result
if (details?.raw) {
const rawText = typeof details.raw === "string" ? details.raw : JSON.stringify(details.raw, null, 2);
const preview = expanded ? rawText : truncate(rawText, 200, uiTheme.format.ellipsis);
const toolLabel = details?.toolName ?? "Exa";
return new Text(
`${uiTheme.fg("success", uiTheme.format.bullet)} ${uiTheme.fg("toolTitle", toolLabel)}\n ${uiTheme.fg("dim", uiTheme.tree.vertical)} ${preview}`,
0,
0,
);
const rawLines = rawText.split("\n").filter((l) => l.trim());
const maxLines = expanded ? rawLines.length : Math.min(rawLines.length, COLLAPSED_PREVIEW_LINES);
const displayLines = rawLines.slice(0, maxLines);
const remaining = rawLines.length - maxLines;
const expandHint = formatExpandHint(expanded, remaining > 0, uiTheme);
let text = `${getStyledStatusIcon("info", uiTheme)} ${uiTheme.fg("dim", "Raw response")}${expandHint}`;
for (let i = 0; i < displayLines.length; i++) {
const isLast = i === displayLines.length - 1 && remaining === 0;
const branch = isLast ? uiTheme.tree.last : uiTheme.tree.branch;
text += `\n ${uiTheme.fg("dim", branch)} ${uiTheme.fg(
"toolOutput",
truncate(displayLines[i], COLLAPSED_PREVIEW_LINE_LEN, uiTheme.format.ellipsis),
)}`;
}
if (remaining > 0) {
text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg(
"muted",
formatMoreItems(remaining, "line", uiTheme),
)}`;
}
return new Text(text, 0, 0);
}
return new Text(uiTheme.fg("error", "No response data"), 0, 0);
return renderEmptyMessage("No response data", uiTheme);
}
const results = response.results ?? [];
@@ -70,118 +94,135 @@ export function renderExaResult(
const cost = response.costDollars?.total;
const time = response.searchTime;
// Build header: Exa Search · N results · $X.XX · Xs
const icon =
resultCount > 0 ? uiTheme.fg("success", uiTheme.format.bullet) : uiTheme.fg("warning", uiTheme.format.bullet);
const expandHint = expanded ? "" : uiTheme.fg("dim", " (Ctrl+O for full results)");
const toolLabel = details?.toolName ?? "Exa Search";
const icon = getStyledStatusIcon(resultCount > 0 ? "success" : "warning", uiTheme);
let headerParts = `${icon} ${uiTheme.fg("toolTitle", toolLabel)}${uiTheme.sep.dot}${uiTheme.fg(
"dim",
`${resultCount} result${resultCount !== 1 ? "s" : ""}`,
)}`;
const metaParts = [formatCount("result", resultCount)];
if (cost !== undefined) metaParts.push(`cost:$${cost.toFixed(4)}`);
if (time !== undefined) metaParts.push(`time:${time.toFixed(2)}s`);
const summaryText = metaParts.join(uiTheme.sep.dot);
if (cost !== undefined) {
headerParts += `${uiTheme.sep.dot}${uiTheme.fg("muted", `$${cost.toFixed(4)}`)}`;
}
if (time !== undefined) {
headerParts += `${uiTheme.sep.dot}${uiTheme.fg("muted", `${time.toFixed(2)}s`)}`;
let hasMorePreview = false;
if (!expanded && resultCount > 0) {
const previewText = results[0].text ?? results[0].title ?? "";
const totalLines = previewText.split("\n").filter((l) => l.trim()).length;
hasMorePreview = totalLines > COLLAPSED_PREVIEW_LINES || resultCount > 1;
}
const expandHint = formatExpandHint(expanded, hasMorePreview, uiTheme);
let text = headerParts + expandHint;
let text = `${icon} ${uiTheme.fg("dim", summaryText)}${expandHint}`;
if (!expanded) {
// Collapsed view: show 3-line preview from first result
if (resultCount > 0) {
const first = results[0];
const previewText = first.text ?? first.title ?? "";
const previewLines = getPreviewLines(previewText, 3, 100, uiTheme.format.ellipsis);
if (resultCount === 0) {
text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg("muted", "No results")}`;
return new Text(text, 0, 0);
}
for (const line of previewLines) {
text += `\n ${uiTheme.fg("dim", uiTheme.tree.vertical)} ${uiTheme.fg("dim", line)}`;
}
const first = results[0];
const previewText = first.text ?? first.title ?? "";
const previewLines = previewText
? getPreviewLines(previewText, COLLAPSED_PREVIEW_LINES, COLLAPSED_PREVIEW_LINE_LEN, uiTheme.format.ellipsis)
: [];
const safePreviewLines = previewLines.length > 0 ? previewLines : ["No preview text"];
const totalLines = previewText.split("\n").filter((l) => l.trim()).length;
const remainingLines = Math.max(0, totalLines - previewLines.length);
const extraItems: string[] = [];
if (remainingLines > 0) {
extraItems.push(formatMoreItems(remainingLines, "line", uiTheme));
}
if (resultCount > 1) {
extraItems.push(formatMoreItems(resultCount - 1, "result", uiTheme));
}
const totalLines = previewText.split("\n").filter((l) => l.trim()).length;
if (totalLines > 3) {
text += `\n ${uiTheme.fg("dim", uiTheme.tree.vertical)} ${uiTheme.fg(
"muted",
`${uiTheme.format.ellipsis} ${totalLines - 3} more lines`,
for (let i = 0; i < safePreviewLines.length; i++) {
const isLast = i === safePreviewLines.length - 1 && extraItems.length === 0;
const branch = isLast ? uiTheme.tree.last : uiTheme.tree.branch;
const line = safePreviewLines[i];
const color = line === "No preview text" ? "muted" : "toolOutput";
text += `\n ${uiTheme.fg("dim", branch)} ${uiTheme.fg(color, line)}`;
}
for (let i = 0; i < extraItems.length; i++) {
const isLast = i === extraItems.length - 1;
const branch = isLast ? uiTheme.tree.last : uiTheme.tree.branch;
text += `\n ${uiTheme.fg("dim", branch)} ${uiTheme.fg("muted", extraItems[i])}`;
}
return new Text(text, 0, 0);
}
if (resultCount === 0) {
text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg("muted", "No results")}`;
return new Text(text, 0, 0);
}
for (let i = 0; i < results.length; i++) {
const res = results[i];
const isLast = i === results.length - 1;
const branch = isLast ? uiTheme.tree.last : uiTheme.tree.branch;
const cont = isLast ? " " : uiTheme.tree.vertical;
const title = truncate(res.title ?? "Untitled", MAX_TITLE_LEN, uiTheme.format.ellipsis);
const domain = res.url ? getDomain(res.url) : "";
const domainPart = domain ? uiTheme.fg("dim", ` (${domain})`) : "";
text += `\n ${uiTheme.fg("dim", branch)} ${uiTheme.fg("accent", title)}${domainPart}`;
if (res.url) {
text += `\n ${uiTheme.fg("dim", cont)} ${uiTheme.fg("dim", uiTheme.tree.hook)} ${uiTheme.fg(
"mdLinkUrl",
res.url,
)}`;
}
if (res.author) {
text += `\n ${uiTheme.fg("dim", cont)} ${uiTheme.fg("dim", uiTheme.tree.hook)} ${uiTheme.fg(
"muted",
`Author: ${res.author}`,
)}`;
}
if (res.publishedDate) {
text += `\n ${uiTheme.fg("dim", cont)} ${uiTheme.fg("dim", uiTheme.tree.hook)} ${uiTheme.fg(
"muted",
`Published: ${res.publishedDate}`,
)}`;
}
if (res.text) {
const textLines = res.text.split("\n").filter((l) => l.trim());
const displayLines = textLines.slice(0, EXPANDED_TEXT_LINES);
for (const line of displayLines) {
text += `\n ${uiTheme.fg("dim", cont)} ${uiTheme.fg("dim", uiTheme.tree.hook)} ${uiTheme.fg(
"toolOutput",
truncate(line.trim(), EXPANDED_TEXT_LINE_LEN, uiTheme.format.ellipsis),
)}`;
}
if (resultCount > 1) {
text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg(
if (textLines.length > EXPANDED_TEXT_LINES) {
text += `\n ${uiTheme.fg("dim", cont)} ${uiTheme.fg("dim", uiTheme.tree.hook)} ${uiTheme.fg(
"muted",
`${resultCount - 1} more result${resultCount !== 2 ? "s" : ""}`,
formatMoreItems(textLines.length - EXPANDED_TEXT_LINES, "line", uiTheme),
)}`;
}
}
} else {
// Expanded view: full results tree
if (resultCount > 0) {
text += `\n ${uiTheme.fg("dim", uiTheme.tree.vertical)}`;
text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg("accent", "Results")}`;
for (let i = 0; i < results.length; i++) {
const res = results[i];
const isLast = i === results.length - 1;
const branch = isLast ? uiTheme.tree.last : uiTheme.tree.branch;
const cont = isLast ? " " : uiTheme.tree.vertical;
// Title + domain
const title = truncate(res.title ?? "Untitled", 60, uiTheme.format.ellipsis);
const domain = res.url ? getDomain(res.url) : "";
const domainPart = domain ? uiTheme.fg("dim", ` (${domain})`) : "";
text += `\n ${uiTheme.fg("dim", " ")} ${uiTheme.fg("dim", branch)} ${uiTheme.fg("accent", title)}${domainPart}`;
// URL
if (res.url) {
text += `\n ${uiTheme.fg("dim", cont)} ${uiTheme.fg("dim", uiTheme.tree.hook)} ${uiTheme.fg("mdLinkUrl", res.url)}`;
}
// Author
if (res.author) {
text += `\n ${uiTheme.fg("dim", cont)} ${uiTheme.fg("muted", `Author: ${res.author}`)}`;
}
// Published date
if (res.publishedDate) {
text += `\n ${uiTheme.fg("dim", cont)} ${uiTheme.fg("muted", `Published: ${res.publishedDate}`)}`;
}
// Text content
if (res.text) {
const textLines = res.text.split("\n").filter((l) => l.trim());
const displayLines = textLines.slice(0, 5); // Show first 5 lines
for (const line of displayLines) {
text += `\n ${uiTheme.fg("dim", cont)} ${truncate(line.trim(), 90, uiTheme.format.ellipsis)}`;
}
if (textLines.length > 5) {
text += `\n ${uiTheme.fg("dim", cont)} ${uiTheme.fg(
"muted",
`${uiTheme.format.ellipsis} ${textLines.length - 5} more lines`,
)}`;
}
}
// Highlights
if (res.highlights?.length) {
text += `\n ${uiTheme.fg("dim", cont)} ${uiTheme.fg("accent", "Highlights:")}`;
for (let j = 0; j < Math.min(res.highlights.length, 3); j++) {
const h = res.highlights[j];
text += `\n ${uiTheme.fg("dim", cont)} ${uiTheme.fg(
"muted",
`${uiTheme.format.bullet} ${truncate(h, 80, uiTheme.format.ellipsis)}`,
)}`;
}
if (res.highlights.length > 3) {
text += `\n ${uiTheme.fg("dim", cont)} ${uiTheme.fg(
"muted",
`${uiTheme.format.ellipsis} ${res.highlights.length - 3} more`,
)}`;
}
}
if (res.highlights?.length) {
text += `\n ${uiTheme.fg("dim", cont)} ${uiTheme.fg("dim", uiTheme.tree.hook)} ${uiTheme.fg(
"accent",
"Highlights",
)}`;
const maxHighlights = Math.min(res.highlights.length, 3);
for (let j = 0; j < maxHighlights; j++) {
const h = res.highlights[j];
text += `\n ${uiTheme.fg("dim", cont)} ${uiTheme.fg("dim", uiTheme.tree.hook)} ${uiTheme.fg(
"muted",
`${uiTheme.format.dash} ${truncate(h, MAX_HIGHLIGHT_LEN, uiTheme.format.ellipsis)}`,
)}`;
}
if (res.highlights.length > maxHighlights) {
text += `\n ${uiTheme.fg("dim", cont)} ${uiTheme.fg("dim", uiTheme.tree.hook)} ${uiTheme.fg(
"muted",
formatMoreItems(res.highlights.length - maxHighlights, "highlight", uiTheme),
)}`;
}
}
}
@@ -191,10 +232,14 @@ export function renderExaResult(
/** Render Exa call (query/args preview) */
export function renderExaCall(args: Record<string, unknown>, toolName: string, uiTheme: Theme): Component {
const query = typeof args.query === "string" ? truncate(args.query, 80, uiTheme.format.ellipsis) : "";
const toolLabel = toolName || "Exa Search";
const query = typeof args.query === "string" ? truncate(args.query, 80, uiTheme.format.ellipsis) : "?";
const numResults = typeof args.num_results === "number" ? args.num_results : undefined;
const detail = numResults ? uiTheme.fg("dim", ` (${numResults} results)`) : "";
const text = `${uiTheme.fg("toolTitle", toolName)} ${uiTheme.fg("muted", query)}${detail}`;
let text = `${uiTheme.fg("toolTitle", toolLabel)} ${uiTheme.fg("accent", query)}`;
if (numResults !== undefined) {
text += ` ${uiTheme.fg("muted", `results:${numResults}`)}`;
}
return new Text(text, 0, 0);
}
@@ -12,6 +12,7 @@ import type { AgentToolResult, RenderResultOptions } from "@oh-my-pi/pi-agent-co
import { Text } from "@oh-my-pi/pi-tui";
import { highlight, supportsLanguage } from "cli-highlight";
import { getLanguageFromPath, type Theme } from "../../../modes/interactive/theme/theme";
import { formatExpandHint, formatMoreItems, TRUNCATE_LENGTHS, truncate } from "../render-utils";
import type { LspParams, LspToolDetails } from "./types";
// =============================================================================
@@ -25,8 +26,8 @@ import type { LspParams, LspToolDetails } from "./types";
export function renderCall(args: unknown, theme: Theme): Text {
const p = args as LspParams & { file?: string; files?: string[] };
let text = theme.fg("toolTitle", theme.bold("LSP "));
text += theme.fg("accent", p.action || "?");
let text = theme.fg("toolTitle", theme.bold("LSP"));
text += ` ${theme.fg("accent", p.action || "?")}`;
if (p.file) {
text += ` ${theme.fg("muted", p.file)}`;
@@ -112,7 +113,7 @@ function renderHover(
const v = theme.boxSharp.vertical;
const top = `${theme.boxSharp.topLeft}${h.repeat(3)}`;
const bottom = `${theme.boxSharp.bottomLeft}${h.repeat(3)}`;
let output = `${icon} ${theme.fg("toolTitle", "Hover")}${langLabel}`;
let output = `${icon}${langLabel}`;
output += `\n ${theme.fg("mdCodeBlockBorder", top)}`;
for (const line of codeLines) {
output += `\n ${theme.fg("mdCodeBlockBorder", v)} ${line}`;
@@ -126,9 +127,10 @@ function renderHover(
// Collapsed view
const firstCodeLine = codeLines[0] || "";
const expandHint = theme.fg("dim", " (Ctrl+O to expand)");
const hasMore = codeLines.length > 1 || Boolean(afterCode);
const expandHint = formatExpandHint(false, hasMore, theme);
let output = `${icon} ${theme.fg("toolTitle", "Hover")}${langLabel}${expandHint}`;
let output = `${icon}${langLabel}${expandHint}`;
const h = theme.boxSharp.horizontal;
const v = theme.boxSharp.vertical;
const bottom = `${theme.boxSharp.bottomLeft}${h.repeat(3)}`;
@@ -142,9 +144,7 @@ function renderHover(
}
if (afterCode) {
const ellipsis = theme.format.ellipsis;
const sliceLen = Math.max(0, 60 - ellipsis.length);
const docPreview = afterCode.length > 60 ? `${afterCode.slice(0, sliceLen)}${ellipsis}` : afterCode;
const docPreview = truncate(afterCode, TRUNCATE_LENGTHS.TITLE, theme.format.ellipsis);
output += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg("muted", docPreview)}`;
} else {
output += `\n ${theme.fg("mdCodeBlockBorder", bottom)}`;
@@ -224,7 +224,7 @@ function renderDiagnostics(
const fallbackDiagnostics: RawDiagnostic[] = diagLines.map((line) => ({ raw: line.trim() }));
if (expanded) {
let output = `${icon} ${theme.fg("toolTitle", "Diagnostics")} ${theme.fg("dim", meta.join(", "))}`;
let output = `${icon} ${theme.fg("dim", meta.join(theme.sep.dot))}`;
const items: DiagnosticItem[] = parsedDiagnostics.length > 0 ? parsedDiagnostics : fallbackDiagnostics;
for (let i = 0; i < items.length; i++) {
const item = items[i];
@@ -242,20 +242,19 @@ function renderDiagnostics(
`[${item.severity}]`,
)}`;
if (item.message) {
output += `\n ${theme.fg("dim", detailPrefix)}${theme.fg("muted", trimTo(item.message, 120, theme))}`;
output += `\n ${theme.fg("dim", detailPrefix)}${theme.fg("muted", truncate(item.message, TRUNCATE_LENGTHS.LINE, theme.format.ellipsis))}`;
}
}
return new Text(output, 0, 0);
}
// Collapsed view
const expandHint = theme.fg("dim", " (Ctrl+O to expand)");
let output = `${icon} ${theme.fg("toolTitle", "Diagnostics")} ${theme.fg("dim", meta.join(", "))}${expandHint}`;
const previewItems: DiagnosticItem[] =
parsedDiagnostics.length > 0 ? parsedDiagnostics.slice(0, 3) : fallbackDiagnostics.slice(0, 3);
const remaining =
(parsedDiagnostics.length > 0 ? parsedDiagnostics.length : fallbackDiagnostics.length) - previewItems.length;
const expandHint = formatExpandHint(false, remaining > 0, theme);
let output = `${icon} ${theme.fg("dim", meta.join(theme.sep.dot))}${expandHint}`;
for (let i = 0; i < previewItems.length; i++) {
const item = previewItems[i];
const isLast = i === previewItems.length - 1 && remaining <= 0;
@@ -266,14 +265,13 @@ function renderDiagnostics(
}
const severityColor = severityToColor(item.severity);
const location = formatDiagnosticLocation(item.file, item.line, item.col, theme);
const message = item.message ? ` ${theme.fg("muted", trimTo(item.message, 80, theme))}` : "";
const message = item.message
? ` ${theme.fg("muted", truncate(item.message, TRUNCATE_LENGTHS.CONTENT, theme.format.ellipsis))}`
: "";
output += `\n ${theme.fg("dim", branch)} ${theme.fg(severityColor, location)}${message}`;
}
if (remaining > 0) {
output += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg(
"muted",
`${theme.format.ellipsis} ${remaining} more`,
)}`;
output += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg("muted", `${theme.format.ellipsis} ${remaining} more`)}`;
}
return new Text(output, 0, 0);
@@ -307,8 +305,8 @@ function renderReferences(refMatch: RegExpMatchArray, lines: string[], expanded:
const files = Array.from(byFile.keys());
const renderGrouped = (maxFiles: number, maxLocsPerFile: number, showHint: boolean): string => {
const expandHint = showHint ? theme.fg("dim", " (Ctrl+O to expand)") : "";
let output = `${icon} ${theme.fg("toolTitle", "References")} ${theme.fg("dim", `${refCount} found`)}${expandHint}`;
const expandHint = formatExpandHint(false, showHint, theme);
let output = `${icon} ${theme.fg("dim", `${refCount} found`)}${expandHint}`;
const filesToShow = files.slice(0, maxFiles);
for (let fi = 0; fi < filesToShow.length; fi++) {
@@ -336,7 +334,7 @@ function renderReferences(refMatch: RegExpMatchArray, lines: string[], expanded:
const context = `at ${file}:${line}:${col}`;
output += `\n ${theme.fg("dim", fileCont)}${theme.fg("dim", locCont)}${theme.fg(
"muted",
trimTo(context, 120, theme),
truncate(context, TRUNCATE_LENGTHS.LINE, theme.format.ellipsis),
)}`;
}
}
@@ -352,7 +350,7 @@ function renderReferences(refMatch: RegExpMatchArray, lines: string[], expanded:
if (files.length > maxFiles) {
output += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg(
"muted",
`${theme.format.ellipsis} ${files.length - maxFiles} more files`,
formatMoreItems(files.length - maxFiles, "file", theme),
)}`;
}
@@ -430,7 +428,7 @@ function renderSymbols(symbolsMatch: RegExpMatchArray, lines: string[], expanded
const topLevelCount = symbols.filter((s) => s.indent === 0).length;
if (expanded) {
let output = `${icon} ${theme.fg("toolTitle", "Symbols")} ${theme.fg("dim", `in ${fileName}`)}`;
let output = `${icon} ${theme.fg("dim", `in ${fileName}`)}`;
for (let i = 0; i < symbols.length; i++) {
const sym = symbols[i];
@@ -448,10 +446,10 @@ function renderSymbols(symbolsMatch: RegExpMatchArray, lines: string[], expanded
}
// Collapsed: show first 3 top-level symbols
const expandHint = theme.fg("dim", " (Ctrl+O to expand)");
let output = `${icon} ${theme.fg("toolTitle", "Symbols")} ${theme.fg("dim", `in ${fileName}`)}${expandHint}`;
const topLevel = symbols.filter((s) => s.indent === 0).slice(0, 3);
const hasMoreSymbols = symbols.length > topLevel.length;
const expandHint = formatExpandHint(false, hasMoreSymbols, theme);
let output = `${icon} ${theme.fg("dim", `in ${fileName}`)}${expandHint}`;
for (let i = 0; i < topLevel.length; i++) {
const sym = topLevel[i];
const isLast = i === topLevel.length - 1 && topLevelCount <= 3;
@@ -462,10 +460,7 @@ function renderSymbols(symbolsMatch: RegExpMatchArray, lines: string[], expanded
)} ${theme.fg("muted", `line ${sym.line}`)}`;
}
if (topLevelCount > 3) {
output += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg(
"muted",
`${theme.format.ellipsis} ${topLevelCount - 3} more`,
)}`;
output += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg("muted", `${theme.format.ellipsis} ${topLevelCount - 3} more`)}`;
}
return new Text(output, 0, 0);
@@ -490,7 +485,7 @@ function renderGeneric(text: string, lines: string[], expanded: boolean, theme:
: theme.styledSymbol("status.info", "accent");
if (expanded) {
let output = `${icon} ${theme.fg("toolTitle", "LSP")} ${theme.fg("dim", "Output")}`;
let output = `${icon} ${theme.fg("dim", "Output")}`;
for (let i = 0; i < lines.length; i++) {
const isLast = i === lines.length - 1;
const branch = isLast ? theme.tree.last : theme.tree.branch;
@@ -500,21 +495,18 @@ function renderGeneric(text: string, lines: string[], expanded: boolean, theme:
}
const firstLine = lines[0] || "No output";
const expandHint = lines.length > 1 ? theme.fg("dim", " (Ctrl+O to expand)") : "";
let output = `${icon} ${theme.fg("toolTitle", "LSP")} ${theme.fg("dim", firstLine.slice(0, 60))}${expandHint}`;
const expandHint = formatExpandHint(false, lines.length > 1, theme);
let output = `${icon} ${theme.fg("dim", truncate(firstLine, TRUNCATE_LENGTHS.TITLE, theme.format.ellipsis))}${expandHint}`;
if (lines.length > 1) {
const previewLines = lines.slice(1, 4);
for (let i = 0; i < previewLines.length; i++) {
const isLast = i === previewLines.length - 1 && lines.length <= 4;
const branch = isLast ? theme.tree.last : theme.tree.branch;
output += `\n ${theme.fg("dim", branch)} ${theme.fg("dim", previewLines[i].trim().slice(0, 80))}`;
output += `\n ${theme.fg("dim", branch)} ${theme.fg("dim", truncate(previewLines[i].trim(), TRUNCATE_LENGTHS.CONTENT, theme.format.ellipsis))}`;
}
if (lines.length > 4) {
output += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg(
"muted",
`${theme.format.ellipsis} ${lines.length - 4} more lines`,
)}`;
output += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg("muted", formatMoreItems(lines.length - 4, "line", theme))}`;
}
}
@@ -558,9 +550,3 @@ function severityToColor(severity: string): "error" | "warning" | "accent" | "di
return "dim";
}
}
function trimTo(value: string, maxLength: number, theme: Theme): string {
if (value.length <= maxLength) return value;
const sliceLen = Math.max(0, maxLength - theme.format.ellipsis.length);
return `${value.slice(0, sliceLen)}${theme.format.ellipsis}`;
}
@@ -0,0 +1,268 @@
/**
* Shared utilities and constants for tool renderers.
*
* Provides consistent formatting, truncation, and display patterns across all
* tool renderers to ensure a unified TUI experience.
*/
import type { Theme } from "../../modes/interactive/theme/theme";
// =============================================================================
// Standardized Display Constants
// =============================================================================
/** Preview limits for collapsed/expanded views */
export const PREVIEW_LIMITS = {
/** Lines shown in collapsed view */
COLLAPSED_LINES: 3,
/** Lines shown in expanded view */
EXPANDED_LINES: 12,
/** Items (files, results) shown in collapsed view */
COLLAPSED_ITEMS: 8,
/** Output preview lines in collapsed view */
OUTPUT_COLLAPSED: 3,
/** Output preview lines in expanded view */
OUTPUT_EXPANDED: 10,
} as const;
/** Truncation lengths for different content types */
export const TRUNCATE_LENGTHS = {
/** Short titles, labels */
TITLE: 60,
/** Medium-length content (messages, previews) */
CONTENT: 80,
/** Longer content (code, explanations) */
LONG: 100,
/** Full line content */
LINE: 110,
/** Very short (task previews, badges) */
SHORT: 40,
} as const;
/** Standard expand hint text */
export const EXPAND_HINT = "(Ctrl+O to expand)";
// =============================================================================
// Text Truncation Utilities
// =============================================================================
/**
* Truncate text to max length with ellipsis.
* The most commonly duplicated utility across renderers.
*/
export function truncate(text: string, maxLen: number, ellipsis: string): string {
if (text.length <= maxLen) return text;
const sliceLen = Math.max(0, maxLen - ellipsis.length);
return `${text.slice(0, sliceLen)}${ellipsis}`;
}
/**
* Get first N lines of text as preview, with each line truncated.
*/
export function getPreviewLines(text: string, maxLines: number, maxLineLen: number, ellipsis: string): string[] {
const lines = text.split("\n").filter((l) => l.trim());
return lines.slice(0, maxLines).map((l) => truncate(l.trim(), maxLineLen, ellipsis));
}
// =============================================================================
// URL Utilities
// =============================================================================
/**
* Extract domain from URL, stripping www. prefix.
*/
export function getDomain(url: string): string {
try {
const u = new URL(url);
return u.hostname.replace(/^www\./, "");
} catch {
return url;
}
}
// =============================================================================
// Formatting Utilities
// =============================================================================
/**
* Format byte count for display (e.g., "1.5KB", "2.3MB").
*/
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes}B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
}
/**
* Format token count for display (e.g., "1.5k", "25k").
*/
export function formatTokens(tokens: number): string {
if (tokens >= 1000) {
return `${(tokens / 1000).toFixed(1)}k`;
}
return String(tokens);
}
/**
* Format duration for display (e.g., "500ms", "2.5s", "1.2m").
*/
export function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
return `${(ms / 60000).toFixed(1)}m`;
}
/**
* Format count with pluralized label (e.g., "3 files", "1 error").
*/
export function formatCount(label: string, count: number): string {
const safeCount = Number.isFinite(count) ? count : 0;
return `${safeCount} ${pluralize(label, safeCount)}`;
}
/**
* Format age from seconds to human-readable string.
*/
export function formatAge(ageSeconds: number | null | undefined): string {
if (!ageSeconds) return "";
const mins = Math.floor(ageSeconds / 60);
const hours = Math.floor(mins / 60);
const days = Math.floor(hours / 24);
const weeks = Math.floor(days / 7);
const months = Math.floor(days / 30);
if (months > 0) return `${months}mo ago`;
if (weeks > 0) return `${weeks}w ago`;
if (days > 0) return `${days}d ago`;
if (hours > 0) return `${hours}h ago`;
if (mins > 0) return `${mins}m ago`;
return "just now";
}
// =============================================================================
// Theme Helper Utilities
// =============================================================================
/**
* Get the appropriate status icon with color for a given state.
* Standardizes status icon usage across all renderers.
*/
export function getStyledStatusIcon(
status: "success" | "error" | "warning" | "info" | "pending" | "running" | "aborted",
theme: Theme,
spinnerFrame?: number,
): string {
switch (status) {
case "success":
return theme.styledSymbol("status.success", "success");
case "error":
return theme.styledSymbol("status.error", "error");
case "warning":
return theme.styledSymbol("status.warning", "warning");
case "info":
return theme.styledSymbol("status.info", "accent");
case "pending":
return theme.styledSymbol("status.pending", "muted");
case "running":
if (spinnerFrame !== undefined) {
const frames = theme.spinnerFrames;
return frames[spinnerFrame % frames.length];
}
return theme.styledSymbol("status.running", "accent");
case "aborted":
return theme.styledSymbol("status.aborted", "error");
}
}
/**
* Format the expand hint with proper theming.
* Returns empty string if already expanded or there is nothing more to show.
*/
export function formatExpandHint(expanded: boolean, hasMore: boolean, theme: Theme): string {
return !expanded && hasMore ? theme.fg("dim", ` ${EXPAND_HINT}`) : "";
}
/**
* Format a badge like [done] or [failed] with brackets and color.
*/
export function formatBadge(
label: string,
color: "success" | "error" | "warning" | "accent" | "muted",
theme: Theme,
): string {
const left = theme.format.bracketLeft;
const right = theme.format.bracketRight;
return theme.fg(color, `${left}${label}${right}`);
}
/**
* Build a "more items" suffix line for truncated lists.
* Uses consistent wording pattern.
*/
export function formatMoreItems(remaining: number, itemType: string, theme: Theme): string {
const safeRemaining = Number.isFinite(remaining) ? remaining : 0;
return `${theme.format.ellipsis} ${safeRemaining} more ${pluralize(itemType, safeRemaining)}`;
}
function pluralize(label: string, count: number): string {
if (count === 1) return label;
if (/(?:ch|sh|s|x|z)$/i.test(label)) return `${label}es`;
if (/[^aeiou]y$/i.test(label)) return `${label.slice(0, -1)}ies`;
return `${label}s`;
}
// =============================================================================
// Tree Rendering Utilities
// =============================================================================
/**
* Get the branch character for a tree item.
*/
export function getTreeBranch(isLast: boolean, theme: Theme): string {
return isLast ? theme.tree.last : theme.tree.branch;
}
/**
* Get the continuation prefix for nested content under a tree item.
*/
export function getTreeContinuePrefix(isLast: boolean, theme: Theme): string {
return isLast ? " " : `${theme.tree.vertical} `;
}
/**
* Render a list of items with tree branches, handling truncation.
*
* @param items - Full list of items to render
* @param expanded - Whether view is expanded
* @param maxCollapsed - Max items to show when collapsed
* @param renderItem - Function to render a single item
* @param itemType - Type name for "more X" message (e.g., "file", "entry")
* @param theme - Theme instance
* @returns Array of formatted lines
*/
export function renderTreeList<T>(
items: T[],
expanded: boolean,
maxCollapsed: number,
renderItem: (item: T, branch: string, isLast: boolean, theme: Theme) => string,
itemType: string,
theme: Theme,
): string[] {
const lines: string[] = [];
const maxItems = expanded ? items.length : Math.min(items.length, maxCollapsed);
for (let i = 0; i < maxItems; i++) {
const isLast = i === maxItems - 1 && (expanded || items.length <= maxCollapsed);
const branch = getTreeBranch(isLast, theme);
lines.push(renderItem(items[i], branch, isLast, theme));
}
if (!expanded && items.length > maxCollapsed) {
const remaining = items.length - maxCollapsed;
lines.push(
` ${theme.fg("dim", theme.tree.last)} ${theme.fg("muted", formatMoreItems(remaining, itemType, theme))}`,
);
}
return lines;
}
+225 -224
View File
@@ -16,6 +16,15 @@ import { renderCall as renderLspCall, renderResult as renderLspResult } from "./
import type { LspToolDetails } from "./lsp/types";
import type { NotebookToolDetails } from "./notebook";
import type { OutputToolDetails } from "./output";
import {
formatBytes,
formatCount,
formatExpandHint,
formatMoreItems,
PREVIEW_LIMITS,
TRUNCATE_LENGTHS,
truncate,
} from "./render-utils";
import { renderCall as renderTaskCall, renderResult as renderTaskResult } from "./task/render";
import type { TaskToolDetails } from "./task/types";
import { renderWebFetchCall, renderWebFetchResult, type WebFetchToolDetails } from "./web-fetch";
@@ -32,6 +41,34 @@ interface ToolRenderer<TArgs = any, TDetails = any> {
): Component;
}
const COLLAPSED_LIST_LIMIT = PREVIEW_LIMITS.COLLAPSED_ITEMS;
const COLLAPSED_TEXT_LIMIT = PREVIEW_LIMITS.COLLAPSED_LINES * 2;
function formatMeta(meta: string[], theme: Theme): string {
return meta.length > 0 ? ` ${theme.fg("muted", meta.join(theme.sep.dot))}` : "";
}
function formatScope(scopePath: string | undefined, theme: Theme): string {
return scopePath ? ` ${theme.fg("muted", `in ${scopePath}`)}` : "";
}
function formatTruncationSuffix(truncated: boolean, theme: Theme): string {
return truncated ? theme.fg("warning", " (truncated)") : "";
}
function renderErrorMessage(_toolLabel: string, message: string, theme: Theme): Text {
const clean = message.replace(/^Error:\s*/, "").trim();
return new Text(
`${theme.styledSymbol("status.error", "error")} ${theme.fg("error", `Error: ${clean || "Unknown error"}`)}`,
0,
0,
);
}
function renderEmptyMessage(_toolLabel: string, message: string, theme: Theme): Text {
return new Text(`${theme.styledSymbol("status.warning", "warning")} ${theme.fg("muted", message)}`, 0, 0);
}
// ============================================================================
// Grep Renderer
// ============================================================================
@@ -52,58 +89,55 @@ interface GrepArgs {
const grepRenderer: ToolRenderer<GrepArgs, GrepToolDetails> = {
renderCall(args, theme) {
let text = theme.fg("toolTitle", theme.bold("grep "));
text += theme.fg("accent", args.pattern || "?");
const label = theme.fg("toolTitle", theme.bold("Grep"));
let text = `${label} ${theme.fg("accent", args.pattern || "?")}`;
const meta: string[] = [];
if (args.path) meta.push(args.path);
if (args.path) meta.push(`in ${args.path}`);
if (args.glob) meta.push(`glob:${args.glob}`);
if (args.type) meta.push(`type:${args.type}`);
if (args.outputMode && args.outputMode !== "files_with_matches") meta.push(args.outputMode);
if (args.outputMode && args.outputMode !== "files_with_matches") meta.push(`mode:${args.outputMode}`);
if (args.caseSensitive) {
meta.push("--case-sensitive");
meta.push("case:sensitive");
} else if (args.ignoreCase) {
meta.push("-i");
meta.push("case:insensitive");
}
if (args.literal) meta.push("literal");
if (args.multiline) meta.push("multiline");
if (args.context !== undefined) meta.push(`context:${args.context}`);
if (args.limit !== undefined) meta.push(`limit:${args.limit}`);
if (meta.length > 0) {
text += ` ${theme.fg("muted", meta.join(" "))}`;
}
text += formatMeta(meta, theme);
return new Text(text, 0, 0);
},
renderResult(result, { expanded }, theme) {
const label = "Grep";
const details = result.details;
// Error case
if (details?.error) {
return new Text(`${theme.styledSymbol("status.error", "error")} ${theme.fg("error", details.error)}`, 0, 0);
return renderErrorMessage(label, details.error, theme);
}
// Check for detailed rendering data - fall back to structured output if not available
const hasDetailedData = details?.matchCount !== undefined || details?.fileCount !== undefined;
if (!hasDetailedData) {
const textContent = result.content?.find((c) => c.type === "text")?.text;
if (!textContent || textContent === "No matches found") {
return new Text(
`${theme.styledSymbol("status.warning", "warning")} ${theme.fg("muted", "No matches found")}`,
0,
0,
);
return renderEmptyMessage(label, "No matches found", theme);
}
const lines = textContent.split("\n").filter((line) => line.trim() !== "");
const maxLines = expanded ? lines.length : 10;
const maxLines = expanded ? lines.length : Math.min(lines.length, COLLAPSED_TEXT_LIMIT);
const displayLines = lines.slice(0, maxLines);
const remaining = lines.length - maxLines;
const hasMore = remaining > 0;
let text = `${theme.styledSymbol("status.success", "success")} ${theme.fg("toolTitle", "grep")} ${theme.fg(
"dim",
`${lines.length} item${lines.length !== 1 ? "s" : ""}`,
)}`;
const icon = theme.styledSymbol("status.success", "success");
const summary = formatCount("item", lines.length);
const expandHint = formatExpandHint(expanded, hasMore, theme);
let text = `${icon} ${theme.fg("dim", summary)}${expandHint}`;
for (let i = 0; i < displayLines.length; i++) {
const isLast = i === displayLines.length - 1 && remaining === 0;
@@ -114,9 +148,10 @@ const grepRenderer: ToolRenderer<GrepArgs, GrepToolDetails> = {
if (remaining > 0) {
text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg(
"muted",
`${theme.format.ellipsis} ${remaining} more items`,
formatMoreItems(remaining, "item", theme),
)}`;
}
return new Text(text, 0, 0);
}
@@ -126,33 +161,29 @@ const grepRenderer: ToolRenderer<GrepArgs, GrepToolDetails> = {
const truncated = details?.truncated ?? details?.truncation?.truncated ?? false;
const files = details?.files ?? [];
// No matches
if (matchCount === 0) {
return new Text(
`${theme.styledSymbol("status.warning", "warning")} ${theme.fg("muted", "No matches found")}`,
0,
0,
);
return renderEmptyMessage(label, "No matches found", theme);
}
// Build summary
const icon = theme.styledSymbol("status.success", "success");
let summary: string;
if (mode === "files_with_matches") {
summary = `${fileCount} file${fileCount !== 1 ? "s" : ""}`;
} else if (mode === "count") {
summary = `${matchCount} match${matchCount !== 1 ? "es" : ""} in ${fileCount} file${fileCount !== 1 ? "s" : ""}`;
} else {
summary = `${matchCount} match${matchCount !== 1 ? "es" : ""} in ${fileCount} file${fileCount !== 1 ? "s" : ""}`;
}
const summaryParts =
mode === "files_with_matches"
? [formatCount("file", fileCount)]
: [formatCount("match", matchCount), formatCount("file", fileCount)];
const summaryText = summaryParts.join(theme.sep.dot);
const scopeLabel = formatScope(details?.scopePath, theme);
if (truncated) {
summary += theme.fg("warning", " (truncated)");
}
const fileEntries: Array<{ path: string; count?: number }> = details?.fileMatches?.length
? details.fileMatches.map((entry) => ({ path: entry.path, count: entry.count }))
: files.map((path) => ({ path }));
const maxFiles = expanded ? fileEntries.length : Math.min(fileEntries.length, COLLAPSED_LIST_LIMIT);
const hasMoreFiles = fileEntries.length > maxFiles;
const expandHint = formatExpandHint(expanded, hasMoreFiles, theme);
const expandHint = expanded ? "" : theme.fg("dim", " (Ctrl+O to expand)");
const scopeLabel = details?.scopePath ? ` ${theme.fg("muted", `in ${details.scopePath}`)}` : "";
let text = `${icon} ${theme.fg("toolTitle", "grep")} ${theme.fg("dim", summary)}${scopeLabel}${expandHint}`;
let text = `${icon} ${theme.fg("dim", summaryText)}${formatTruncationSuffix(
truncated,
theme,
)}${scopeLabel}${expandHint}`;
const truncationReasons: string[] = [];
if (details?.matchLimitReached) {
@@ -168,16 +199,12 @@ const grepRenderer: ToolRenderer<GrepArgs, GrepToolDetails> = {
truncationReasons.push("line length");
}
const fileEntries: Array<{ path: string; count?: number }> = details?.fileMatches?.length
? details.fileMatches.map((entry) => ({ path: entry.path, count: entry.count }))
: files.map((path) => ({ path }));
const hasTruncation = truncationReasons.length > 0;
// Show file tree if we have files
if (fileEntries.length > 0) {
const maxFiles = expanded ? fileEntries.length : Math.min(fileEntries.length, 8);
for (let i = 0; i < maxFiles; i++) {
const entry = fileEntries[i];
const isLast = i === maxFiles - 1 && (expanded || fileEntries.length <= 8);
const isLast = i === maxFiles - 1 && !hasMoreFiles && !hasTruncation;
const branch = isLast ? theme.tree.last : theme.tree.branch;
const isDir = entry.path.endsWith("/");
const entryPath = isDir ? entry.path.slice(0, -1) : entry.path;
@@ -192,15 +219,16 @@ const grepRenderer: ToolRenderer<GrepArgs, GrepToolDetails> = {
text += `\n ${theme.fg("dim", branch)} ${entryIcon} ${theme.fg("accent", entry.path)}${countLabel}`;
}
if (!expanded && fileEntries.length > 8) {
text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg(
if (hasMoreFiles) {
const moreFilesBranch = hasTruncation ? theme.tree.branch : theme.tree.last;
text += `\n ${theme.fg("dim", moreFilesBranch)} ${theme.fg(
"muted",
`${theme.format.ellipsis} ${fileEntries.length - 8} more files`,
formatMoreItems(fileEntries.length - maxFiles, "file", theme),
)}`;
}
}
if (truncationReasons.length > 0) {
if (hasTruncation) {
text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg(
"warning",
`truncated: ${truncationReasons.join(", ")}`,
@@ -226,54 +254,48 @@ interface FindArgs {
const findRenderer: ToolRenderer<FindArgs, FindToolDetails> = {
renderCall(args, theme) {
let text = theme.fg("toolTitle", theme.bold("find "));
text += theme.fg("accent", args.pattern || "*");
const label = theme.fg("toolTitle", theme.bold("Find"));
let text = `${label} ${theme.fg("accent", args.pattern || "*")}`;
const meta: string[] = [];
if (args.path) meta.push(args.path);
if (args.path) meta.push(`in ${args.path}`);
if (args.type && args.type !== "all") meta.push(`type:${args.type}`);
if (args.hidden) meta.push("--hidden");
if (args.hidden) meta.push("hidden");
if (args.sortByMtime) meta.push("sort:mtime");
if (args.limit !== undefined) meta.push(`limit:${args.limit}`);
if (meta.length > 0) {
text += ` ${theme.fg("muted", meta.join(" "))}`;
}
text += formatMeta(meta, theme);
return new Text(text, 0, 0);
},
renderResult(result, { expanded }, theme) {
const label = "Find";
const details = result.details;
// Error case
if (details?.error) {
return new Text(`${theme.styledSymbol("status.error", "error")} ${theme.fg("error", details.error)}`, 0, 0);
return renderErrorMessage(label, details.error, theme);
}
// Check for detailed rendering data - fall back to parsing raw output if not available
const hasDetailedData = details?.fileCount !== undefined;
// Get text content for fallback or to extract file list
const textContent = result.content?.find((c) => c.type === "text")?.text;
if (!hasDetailedData) {
if (!textContent || textContent.includes("No files matching") || textContent.trim() === "") {
return new Text(
`${theme.styledSymbol("status.warning", "warning")} ${theme.fg("muted", "No files found")}`,
0,
0,
);
return renderEmptyMessage(label, "No files found", theme);
}
// Parse the raw output as file list
const lines = textContent.split("\n").filter((l) => l.trim());
const maxLines = expanded ? lines.length : Math.min(lines.length, 8);
const maxLines = expanded ? lines.length : Math.min(lines.length, COLLAPSED_LIST_LIMIT);
const displayLines = lines.slice(0, maxLines);
const remaining = lines.length - maxLines;
const hasMore = remaining > 0;
const icon = theme.styledSymbol("status.success", "success");
const summary = formatCount("file", lines.length);
const expandHint = formatExpandHint(expanded, hasMore, theme);
let text = `${icon} ${theme.fg("dim", summary)}${expandHint}`;
let text = `${theme.styledSymbol("status.success", "success")} ${theme.fg("toolTitle", "find")} ${theme.fg(
"dim",
`${lines.length} file${lines.length !== 1 ? "s" : ""}`,
)}`;
for (let i = 0; i < displayLines.length; i++) {
const isLast = i === displayLines.length - 1 && remaining === 0;
const branch = isLast ? theme.tree.last : theme.tree.branch;
@@ -282,7 +304,7 @@ const findRenderer: ToolRenderer<FindArgs, FindToolDetails> = {
if (remaining > 0) {
text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg(
"muted",
`${theme.format.ellipsis} ${remaining} more files`,
formatMoreItems(remaining, "file", theme),
)}`;
}
return new Text(text, 0, 0);
@@ -292,26 +314,21 @@ const findRenderer: ToolRenderer<FindArgs, FindToolDetails> = {
const truncated = details?.truncated ?? details?.truncation?.truncated ?? false;
const files = details?.files ?? [];
// No matches
if (fileCount === 0) {
return new Text(
`${theme.styledSymbol("status.warning", "warning")} ${theme.fg("muted", "No files found")}`,
0,
0,
);
return renderEmptyMessage(label, "No files found", theme);
}
// Build summary
const icon = theme.styledSymbol("status.success", "success");
let summary = `${fileCount} file${fileCount !== 1 ? "s" : ""}`;
const summaryText = formatCount("file", fileCount);
const scopeLabel = formatScope(details?.scopePath, theme);
const maxFiles = expanded ? files.length : Math.min(files.length, COLLAPSED_LIST_LIMIT);
const hasMoreFiles = files.length > maxFiles;
const expandHint = formatExpandHint(expanded, hasMoreFiles, theme);
if (truncated) {
summary += theme.fg("warning", " (truncated)");
}
const expandHint = expanded ? "" : theme.fg("dim", " (Ctrl+O to expand)");
const scopeLabel = details?.scopePath ? ` ${theme.fg("muted", `in ${details.scopePath}`)}` : "";
let text = `${icon} ${theme.fg("toolTitle", "find")} ${theme.fg("dim", summary)}${scopeLabel}${expandHint}`;
let text = `${icon} ${theme.fg("dim", summaryText)}${formatTruncationSuffix(
truncated,
theme,
)}${scopeLabel}${expandHint}`;
const truncationReasons: string[] = [];
if (details?.resultLimitReached) {
@@ -321,11 +338,11 @@ const findRenderer: ToolRenderer<FindArgs, FindToolDetails> = {
truncationReasons.push("size limit");
}
// Show file tree if we have files
const hasTruncation = truncationReasons.length > 0;
if (files.length > 0) {
const maxFiles = expanded ? files.length : Math.min(files.length, 8);
for (let i = 0; i < maxFiles; i++) {
const isLast = i === maxFiles - 1 && (expanded || files.length <= 8);
const isLast = i === maxFiles - 1 && !hasMoreFiles && !hasTruncation;
const branch = isLast ? theme.tree.last : theme.tree.branch;
const entry = files[i];
const isDir = entry.endsWith("/");
@@ -337,15 +354,16 @@ const findRenderer: ToolRenderer<FindArgs, FindToolDetails> = {
text += `\n ${theme.fg("dim", branch)} ${entryIcon} ${theme.fg("accent", entry)}`;
}
if (!expanded && files.length > 8) {
text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg(
if (hasMoreFiles) {
const moreFilesBranch = hasTruncation ? theme.tree.branch : theme.tree.last;
text += `\n ${theme.fg("dim", moreFilesBranch)} ${theme.fg(
"muted",
`${theme.format.ellipsis} ${files.length - 8} more files`,
formatMoreItems(files.length - maxFiles, "file", theme),
)}`;
}
}
if (truncationReasons.length > 0) {
if (hasTruncation) {
text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg(
"warning",
`truncated: ${truncationReasons.join(", ")}`,
@@ -378,7 +396,7 @@ function renderCellPreview(lines: string[], expanded: boolean, theme: Theme): st
return `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg("muted", "(empty cell)")}`;
}
const maxLines = expanded ? normalized.length : Math.min(normalized.length, 6);
const maxLines = expanded ? normalized.length : Math.min(normalized.length, COLLAPSED_TEXT_LIMIT);
let text = "";
for (let i = 0; i < maxLines; i++) {
@@ -390,10 +408,7 @@ function renderCellPreview(lines: string[], expanded: boolean, theme: Theme): st
const remaining = normalized.length - maxLines;
if (remaining > 0) {
text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg(
"muted",
`${theme.format.ellipsis} ${remaining} more lines`,
)}`;
text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg("muted", formatMoreItems(remaining, "line", theme))}`;
}
return text;
@@ -401,28 +416,26 @@ function renderCellPreview(lines: string[], expanded: boolean, theme: Theme): st
const notebookRenderer: ToolRenderer<NotebookArgs, NotebookToolDetails> = {
renderCall(args, theme) {
let text = theme.fg("toolTitle", theme.bold("notebook "));
text += theme.fg("accent", args.action || "?");
const label = theme.fg("toolTitle", theme.bold("Notebook"));
let text = `${label} ${theme.fg("accent", args.action || "?")}`;
const meta: string[] = [];
meta.push(args.notebookPath || "?");
meta.push(`in ${args.notebookPath || "?"}`);
if (args.cellNumber !== undefined) meta.push(`cell:${args.cellNumber}`);
if (args.cellType) meta.push(args.cellType);
if (args.cellType) meta.push(`type:${args.cellType}`);
if (meta.length > 0) {
text += ` ${theme.fg("muted", meta.join(" "))}`;
}
text += formatMeta(meta, theme);
return new Text(text, 0, 0);
},
renderResult(result, { expanded }, theme) {
const label = "Notebook";
const details = result.details;
// Error case - check for error in content
const content = result.content?.[0];
if (content?.type === "text" && content.text?.startsWith("Error:")) {
return new Text(`${theme.styledSymbol("status.error", "error")} ${theme.fg("error", content.text)}`, 0, 0);
return renderErrorMessage(label, content.text, theme);
}
const action = details?.action ?? "edit";
@@ -431,33 +444,18 @@ const notebookRenderer: ToolRenderer<NotebookArgs, NotebookToolDetails> = {
const totalCells = details?.totalCells;
const cellSource = details?.cellSource;
const lineCount = cellSource?.length;
const canExpand = cellSource !== undefined && cellSource.length > 6;
const canExpand = cellSource !== undefined && cellSource.length > COLLAPSED_TEXT_LIMIT;
// Build summary
const icon = theme.styledSymbol("status.success", "success");
let summary: string;
const actionLabel = action === "insert" ? "Inserted" : action === "delete" ? "Deleted" : "Edited";
const cellLabel = cellType || "cell";
const summaryParts = [`${actionLabel} ${cellLabel} at index ${cellIndex ?? "?"}`];
if (lineCount !== undefined) summaryParts.push(formatCount("line", lineCount));
if (totalCells !== undefined) summaryParts.push(`${totalCells} total`);
const summaryText = summaryParts.join(theme.sep.dot);
switch (action) {
case "insert":
summary = `Inserted ${cellType || "cell"} at index ${cellIndex}`;
break;
case "delete":
summary = `Deleted cell at index ${cellIndex}`;
break;
default:
summary = `Edited ${cellType || "cell"} at index ${cellIndex}`;
}
if (lineCount !== undefined) {
summary += ` (${lineCount} line${lineCount !== 1 ? "s" : ""})`;
}
if (totalCells !== undefined) {
summary += ` (${totalCells} total)`;
}
const expandHint = !expanded && canExpand ? theme.fg("dim", " (Ctrl+O to expand)") : "";
let text = `${icon} ${theme.fg("toolTitle", "notebook")} ${theme.fg("dim", summary)}${expandHint}`;
const expandHint = formatExpandHint(expanded, canExpand, theme);
let text = `${icon} ${theme.fg("dim", summaryText)}${expandHint}`;
if (cellSource) {
text += renderCellPreview(cellSource, expanded, theme);
@@ -480,15 +478,26 @@ interface AskArgs {
const askRenderer: ToolRenderer<AskArgs, AskToolDetails> = {
renderCall(args, theme) {
if (!args.question) {
return new Text(theme.fg("error", "ask: no question provided"), 0, 0);
return renderErrorMessage("Ask", "No question provided", theme);
}
const multiTag = args.multi ? theme.fg("muted", " [multi-select]") : "";
let text = theme.fg("toolTitle", "? ") + theme.fg("accent", args.question) + multiTag;
const label = theme.fg("toolTitle", theme.bold("Ask"));
let text = `${label} ${theme.fg("accent", args.question)}`;
const meta: string[] = [];
if (args.multi) meta.push("multi");
if (args.options?.length) meta.push(`options:${args.options.length}`);
text += formatMeta(meta, theme);
if (args.options?.length) {
for (const opt of args.options) {
text += `\n${theme.fg("dim", ` ${theme.checkbox.unchecked} `)}${theme.fg("muted", opt.label)}`;
for (let i = 0; i < args.options.length; i++) {
const opt = args.options[i];
const isLast = i === args.options.length - 1;
const branch = isLast ? theme.tree.last : theme.tree.branch;
text += `\n ${theme.fg("dim", branch)} ${theme.fg(
"dim",
theme.checkbox.unchecked,
)} ${theme.fg("muted", opt.label)}`;
}
}
@@ -507,7 +516,7 @@ const askRenderer: ToolRenderer<AskArgs, AskToolDetails> = {
? theme.styledSymbol("status.success", "success")
: theme.styledSymbol("status.warning", "warning");
let text = `${statusIcon} ${theme.fg("toolTitle", "ask")} ${theme.fg("accent", details.question)}`;
let text = `${statusIcon} ${theme.fg("accent", details.question)}`;
if (details.customInput) {
text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.styledSymbol(
@@ -565,23 +574,10 @@ interface OutputArgs {
format?: "raw" | "json" | "stripped";
}
/** Format byte count for display */
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes}B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}K`;
return `${(bytes / (1024 * 1024)).toFixed(1)}M`;
}
function truncateLine(text: string, maxLen: number, ellipsis: string): string {
if (text.length <= maxLen) return text;
const sliceLen = Math.max(0, maxLen - ellipsis.length);
return `${text.slice(0, sliceLen)}${ellipsis}`;
}
type OutputEntry = OutputToolDetails["outputs"][number];
function formatOutputMeta(entry: OutputEntry, theme: Theme): string {
const metaParts = [`${entry.lineCount} lines, ${formatBytes(entry.charCount)}`];
const metaParts = [formatCount("line", entry.lineCount), formatBytes(entry.charCount)];
if (entry.provenance) {
metaParts.push(`agent ${entry.provenance.agent}(${entry.provenance.index})`);
}
@@ -591,48 +587,56 @@ function formatOutputMeta(entry: OutputEntry, theme: Theme): string {
const outputRenderer: ToolRenderer<OutputArgs, OutputToolDetails> = {
renderCall(args, theme) {
const ids = args.ids?.join(", ") ?? "?";
const label = theme.fg("toolTitle", theme.bold("output"));
const format = args.format && args.format !== "raw" ? theme.fg("muted", ` (${args.format})`) : "";
return new Text(`${label} ${theme.fg("dim", ids)}${format}`, 0, 0);
const label = theme.fg("toolTitle", theme.bold("Output"));
let text = `${label} ${theme.fg("accent", ids)}`;
const meta: string[] = [];
if (args.format && args.format !== "raw") meta.push(`format:${args.format}`);
text += formatMeta(meta, theme);
return new Text(text, 0, 0);
},
renderResult(result, { expanded }, theme) {
const label = "Output";
const details = result.details;
// Error case: some IDs not found
if (details?.notFound?.length) {
let text = `${theme.styledSymbol("status.error", "error")} Not found: ${details.notFound.join(", ")}`;
const icon = theme.styledSymbol("status.error", "error");
let text = `${icon} ${theme.fg("error", `Error: Not found: ${details.notFound.join(", ")}`)}`;
if (details.availableIds?.length) {
text += `\n${theme.fg("dim", "Available:")} ${details.availableIds.join(", ")}`;
text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg(
"muted",
`Available: ${details.availableIds.join(", ")}`,
)}`;
} else {
text += `\n${theme.fg("dim", "No outputs available in current session")}`;
text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg(
"muted",
"No outputs available in current session",
)}`;
}
return new Text(text, 0, 0);
}
const outputs = details?.outputs ?? [];
// No session case
if (outputs.length === 0) {
const textContent = result.content?.find((c) => c.type === "text")?.text;
return new Text(
`${theme.styledSymbol("status.warning", "warning")} ${theme.fg("muted", textContent || "No outputs")}`,
0,
0,
);
return renderEmptyMessage(label, textContent || "No outputs", theme);
}
// Success: summary + tree display
const expandHint = expanded ? "" : theme.fg("dim", " (Ctrl+O to expand)");
const icon = theme.styledSymbol("status.success", "success");
const summary = `read ${outputs.length} output${outputs.length !== 1 ? "s" : ""}`;
let text = `${icon} ${theme.fg("toolTitle", "output")} ${theme.fg("dim", summary)}${expandHint}`;
const summary = `read ${formatCount("output", outputs.length)}`;
const previewLimit = expanded ? 3 : 1;
const maxOutputs = expanded ? outputs.length : Math.min(outputs.length, 5);
const hasMoreOutputs = outputs.length > maxOutputs;
const hasMorePreview = outputs.some((o) => (o.previewLines?.length ?? 0) > previewLimit);
const expandHint = formatExpandHint(expanded, hasMoreOutputs || hasMorePreview, theme);
let text = `${icon} ${theme.fg("dim", summary)}${expandHint}`;
for (let i = 0; i < maxOutputs; i++) {
const o = outputs[i];
const isLast = i === maxOutputs - 1 && (expanded || outputs.length <= 5);
const isLast = i === maxOutputs - 1 && !hasMoreOutputs;
const branch = isLast ? theme.tree.last : theme.tree.branch;
text += `\n ${theme.fg("dim", branch)} ${theme.fg("accent", o.id)} ${formatOutputMeta(o, theme)}`;
@@ -641,19 +645,19 @@ const outputRenderer: ToolRenderer<OutputArgs, OutputToolDetails> = {
if (shownPreview.length > 0) {
const childPrefix = isLast ? " " : ` ${theme.fg("dim", theme.tree.vertical)} `;
for (const line of shownPreview) {
const previewText = truncateLine(line, 80, theme.format.ellipsis);
text += `\n${childPrefix}${theme.fg("dim", theme.tree.hook)} ${theme.fg("muted", "preview:")} ${theme.fg(
"toolOutput",
previewText,
)}`;
const previewText = truncate(line, TRUNCATE_LENGTHS.CONTENT, theme.format.ellipsis);
text += `\n${childPrefix}${theme.fg("dim", theme.tree.hook)} ${theme.fg(
"muted",
"preview:",
)} ${theme.fg("toolOutput", previewText)}`;
}
}
}
if (!expanded && outputs.length > 5) {
if (hasMoreOutputs) {
text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg(
"muted",
`${theme.format.ellipsis} ${outputs.length - 5} more outputs`,
formatMoreItems(outputs.length - maxOutputs, "output", theme),
)}`;
}
@@ -681,27 +685,26 @@ interface LsArgs {
const lsRenderer: ToolRenderer<LsArgs, LsToolDetails> = {
renderCall(args, theme) {
let text = theme.fg("toolTitle", theme.bold("ls "));
text += theme.fg("accent", args.path || ".");
if (args.limit !== undefined) {
text += ` ${theme.fg("muted", `(limit ${args.limit})`)}`;
}
const label = theme.fg("toolTitle", theme.bold("Ls"));
let text = `${label} ${theme.fg("accent", args.path || ".")}`;
const meta: string[] = [];
if (args.limit !== undefined) meta.push(`limit:${args.limit}`);
text += formatMeta(meta, theme);
return new Text(text, 0, 0);
},
renderResult(result, { expanded }, theme) {
const label = "Ls";
const details = result.details;
const textContent = result.content?.find((c: any) => c.type === "text")?.text ?? "";
const textContent = result.content?.find((c) => c.type === "text")?.text ?? "";
if (
(!textContent || textContent.trim() === "" || textContent.trim() === "(empty directory)") &&
(!details?.entries || details.entries.length === 0)
) {
return new Text(
`${theme.styledSymbol("status.warning", "warning")} ${theme.fg("muted", "Empty directory")}`,
0,
0,
);
return renderEmptyMessage(label, "Empty directory", theme);
}
let entries: string[] = details?.entries ? [...details.entries] : [];
@@ -711,11 +714,7 @@ const lsRenderer: ToolRenderer<LsArgs, LsToolDetails> = {
}
if (entries.length === 0) {
return new Text(
`${theme.styledSymbol("status.warning", "warning")} ${theme.fg("muted", "Empty directory")}`,
0,
0,
);
return renderEmptyMessage(label, "Empty directory", theme);
}
let dirCount = details?.dirCount;
@@ -737,45 +736,47 @@ const lsRenderer: ToolRenderer<LsArgs, LsToolDetails> = {
? theme.styledSymbol("status.warning", "warning")
: theme.styledSymbol("status.success", "success");
const dirLabel = `${dirCount} dir${dirCount !== 1 ? "s" : ""}`;
const fileLabel = `${fileCount} file${fileCount !== 1 ? "s" : ""}`;
let text = `${icon} ${theme.fg("toolTitle", "ls")} ${theme.fg("dim", `${dirLabel}, ${fileLabel}`)}`;
const summaryText = [formatCount("dir", dirCount ?? 0), formatCount("file", fileCount ?? 0)].join(theme.sep.dot);
const maxEntries = expanded ? entries.length : Math.min(entries.length, COLLAPSED_LIST_LIMIT);
const hasMoreEntries = entries.length > maxEntries;
const expandHint = formatExpandHint(expanded, hasMoreEntries, theme);
if (truncated) {
const reasonParts: string[] = [];
if (details?.entryLimitReached) {
reasonParts.push(`entry limit ${details.entryLimitReached}`);
}
if (details?.truncation?.truncated) {
reasonParts.push(`output cap ${formatBytes(details.truncation.maxBytes)}`);
}
const reasonText = reasonParts.length > 0 ? `truncated: ${reasonParts.join(", ")}` : "truncated";
text += ` ${theme.fg("warning", `(${reasonText})`)}`;
let text = `${icon} ${theme.fg("dim", summaryText)}${formatTruncationSuffix(truncated, theme)}${expandHint}`;
const truncationReasons: string[] = [];
if (details?.entryLimitReached) {
truncationReasons.push(`entry limit ${details.entryLimitReached}`);
}
if (details?.truncation?.truncated) {
truncationReasons.push(`output cap ${formatBytes(details.truncation.maxBytes)}`);
}
if (!expanded) {
text += `\n${theme.fg("dim", `${theme.nav.expand} Ctrl+O to expand list`)}`;
}
const hasTruncation = truncationReasons.length > 0;
const maxEntries = expanded ? entries.length : Math.min(entries.length, 12);
for (let i = 0; i < maxEntries; i++) {
const entry = entries[i];
const isLast = i === maxEntries - 1 && (expanded || entries.length <= 12);
const isLast = i === maxEntries - 1 && !hasMoreEntries && !hasTruncation;
const branch = isLast ? theme.tree.last : theme.tree.branch;
const isDir = entry.endsWith("/");
const entryPath = isDir ? entry.slice(0, -1) : entry;
const lang = isDir ? undefined : getLanguageFromPath(entryPath);
const entryIcon = isDir
? theme.fg("accent", theme.icon.folder)
: theme.fg("muted", theme.getLangIcon(lang));
const entryIcon = isDir ? theme.fg("accent", theme.icon.folder) : theme.fg("muted", theme.getLangIcon(lang));
const entryColor = isDir ? "accent" : "toolOutput";
text += `\n ${theme.fg("dim", branch)} ${entryIcon} ${theme.fg(entryColor, entry)}`;
}
if (!expanded && entries.length > 12) {
text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg(
if (hasMoreEntries) {
const moreEntriesBranch = hasTruncation ? theme.tree.branch : theme.tree.last;
text += `\n ${theme.fg("dim", moreEntriesBranch)} ${theme.fg(
"muted",
`${theme.format.ellipsis} ${entries.length - 12} more entries`,
formatMoreItems(entries.length - maxEntries, "entry", theme),
)}`;
}
if (hasTruncation) {
text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg(
"warning",
`truncated: ${truncationReasons.join(", ")}`,
)}`;
}
@@ -12,6 +12,7 @@ import * as path from "node:path";
import * as readline from "node:readline";
import { ensureArtifactsDir, getArtifactPaths } from "./artifacts";
import { resolveModelPattern } from "./model-resolver";
import { resolveOmpCommand } from "./omp-command";
import { subprocessToolRegistry } from "./subprocess-tool-registry";
import {
type AgentDefinition,
@@ -23,12 +24,6 @@ import {
type SingleResult,
} from "./types";
/** omp command: 'omp.cmd' on Windows, 'omp' elsewhere */
const OMP_CMD = process.platform === "win32" ? "omp.cmd" : "omp";
/** Windows shell option for spawn */
const OMP_SHELL_OPT = process.platform === "win32";
/** Options for subprocess execution */
export interface ExecutorOptions {
cwd: string;
@@ -268,10 +263,11 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
}
// Spawn subprocess
const proc = spawn(OMP_CMD, args, {
const ompCommand = resolveOmpCommand();
const proc = spawn(ompCommand.cmd, [...ompCommand.args, ...args], {
cwd,
stdio: ["ignore", "pipe", "pipe"],
shell: OMP_SHELL_OPT,
shell: ompCommand.shell,
env,
});
@@ -16,11 +16,12 @@
import type { AgentTool } from "@oh-my-pi/pi-agent-core";
import type { Usage } from "@oh-my-pi/pi-ai";
import type { Theme } from "../../../modes/interactive/theme/theme";
import { formatDuration } from "../render-utils";
import { cleanupTempDir, createTempArtifactsDir, getArtifactsDir } from "./artifacts";
import { discoverAgents, getAgent } from "./discovery";
import { runSubprocess } from "./executor";
import { mapWithConcurrencyLimit } from "./parallel";
import { formatDuration, renderCall, renderResult } from "./render";
import { renderCall, renderResult } from "./render";
import {
type AgentProgress,
MAX_AGENTS_IN_DESCRIPTION,
@@ -14,12 +14,7 @@
import { spawnSync } from "node:child_process";
import { type Settings, settingsCapability } from "../../../capability/settings";
import { loadSync } from "../../../discovery";
/** omp command: 'omp.cmd' on Windows, 'omp' elsewhere */
const OMP_CMD = process.platform === "win32" ? "omp.cmd" : "omp";
/** Windows shell option for spawn/spawnSync */
const OMP_SHELL_OPT = process.platform === "win32";
import { resolveOmpCommand } from "./omp-command";
/** Cache for available models (provider/modelId format) */
let cachedModels: string[] | null = null;
@@ -41,10 +36,11 @@ export function getAvailableModels(): string[] {
}
try {
const result = spawnSync(OMP_CMD, ["--list-models"], {
const ompCommand = resolveOmpCommand();
const result = spawnSync(ompCommand.cmd, [...ompCommand.args, "--list-models"], {
encoding: "utf-8",
timeout: 5000,
shell: OMP_SHELL_OPT,
shell: ompCommand.shell,
});
if (result.status !== 0 || !result.stdout) {
@@ -0,0 +1,24 @@
import process from "node:process";
interface OmpCommand {
cmd: string;
args: string[];
shell: boolean;
}
const DEFAULT_CMD = process.platform === "win32" ? "omp.cmd" : "omp";
const DEFAULT_SHELL = process.platform === "win32";
export function resolveOmpCommand(): OmpCommand {
const envCmd = process.env.OMP_SUBPROCESS_CMD;
if (envCmd?.trim()) {
return { cmd: envCmd, args: [], shell: DEFAULT_SHELL };
}
const entry = process.argv[1];
if (entry && (entry.endsWith(".ts") || entry.endsWith(".js"))) {
return { cmd: process.execPath, args: [entry], shell: false };
}
return { cmd: DEFAULT_CMD, args: [], shell: DEFAULT_SHELL };
}
@@ -10,6 +10,14 @@ import type { Component } from "@oh-my-pi/pi-tui";
import { Container, Text } from "@oh-my-pi/pi-tui";
import type { Theme } from "../../../modes/interactive/theme/theme";
import type { RenderResultOptions } from "../../custom-tools/types";
import {
formatBadge,
formatDuration,
formatMoreItems,
formatTokens,
getStyledStatusIcon,
truncate,
} from "../render-utils";
import type { ReportFindingDetails, SubmitReviewDetails } from "../review";
import { subprocessToolRegistry } from "./subprocess-tool-registry";
import type { AgentProgress, SingleResult, TaskParams, TaskToolDetails } from "./types";
@@ -22,63 +30,26 @@ const PRIORITY_LABELS: Record<number, string> = {
3: "P3",
};
/**
* Format token count for display (e.g., 1.5k, 25k).
*/
function formatTokens(tokens: number): string {
if (tokens >= 1000) {
return `${(tokens / 1000).toFixed(1)}k`;
}
return String(tokens);
}
/**
* Format duration for display.
*/
export function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
return `${(ms / 60000).toFixed(1)}m`;
}
/**
* Truncate text to max length with ellipsis.
*/
function truncate(text: string, maxLen: number, ellipsis: string): string {
if (text.length <= maxLen) return text;
const sliceLen = Math.max(0, maxLen - ellipsis.length);
return `${text.slice(0, sliceLen)}${ellipsis}`;
}
/**
* Get status icon for agent state.
* For running status, uses animated spinner if spinnerFrame is provided.
* Maps AgentProgress status to styled icon format.
*/
function getStatusIcon(status: AgentProgress["status"], theme: Theme, spinnerFrame?: number): string {
switch (status) {
case "pending":
return theme.status.pending;
case "running": {
// Use animated spinner if frame is provided, otherwise static icon
if (spinnerFrame === undefined) return theme.status.running;
const frames = theme.spinnerFrames;
return frames[spinnerFrame % frames.length];
}
return getStyledStatusIcon("pending", theme);
case "running":
return getStyledStatusIcon("running", theme, spinnerFrame);
case "completed":
return theme.status.success;
return getStyledStatusIcon("success", theme);
case "failed":
return theme.status.error;
return getStyledStatusIcon("error", theme);
case "aborted":
return theme.status.aborted;
return getStyledStatusIcon("aborted", theme);
}
}
function formatBadge(label: string, color: "success" | "error" | "warning" | "accent" | "muted", theme: Theme): string {
const left = theme.format.bracketLeft;
const right = theme.format.bracketRight;
return theme.fg(color, `${left}${label}${right}`);
}
function formatFindingSummary(findings: ReportFindingDetails[], theme: Theme): string {
if (findings.length === 0) return theme.fg("dim", "Findings: none");
@@ -119,10 +90,7 @@ function renderOutputSection(
if (outputLines.length > previewCount) {
lines.push(
`${continuePrefix} ${theme.fg(
"dim",
`${theme.format.ellipsis} ${outputLines.length - previewCount} more lines`,
)}`,
`${continuePrefix} ${theme.fg("dim", formatMoreItems(outputLines.length - previewCount, "line", theme))}`,
);
}
@@ -133,7 +101,7 @@ function renderOutputSection(
* Render the tool call arguments.
*/
export function renderCall(args: TaskParams, theme: Theme): Component {
const label = theme.fg("toolTitle", theme.bold("task"));
const label = theme.fg("toolTitle", theme.bold("Task"));
if (args.tasks.length === 1) {
// Single task - show agent and task preview
@@ -235,9 +203,7 @@ function renderAgentProgress(
}
}
if (dataArray.length > 3) {
lines.push(
`${continuePrefix}${theme.fg("dim", `${theme.format.ellipsis} ${dataArray.length - 3} more`)}`,
);
lines.push(`${continuePrefix}${theme.fg("dim", formatMoreItems(dataArray.length - 3, "item", theme))}`);
}
}
}
@@ -337,9 +303,7 @@ function renderFindings(
}
if (!expanded && findings.length > 3) {
lines.push(
`${continuePrefix}${theme.fg("dim", `${theme.format.ellipsis} ${findings.length - 3} more findings`)}`,
);
lines.push(`${continuePrefix}${theme.fg("dim", formatMoreItems(findings.length - 3, "finding", theme))}`);
}
return lines;
@@ -479,11 +443,11 @@ export function renderResult(
let summary = `\n${theme.fg("dim", "Total:")} `;
if (abortedCount > 0) {
summary += theme.fg("error", `${abortedCount} aborted`);
if (successCount > 0 || failCount > 0) summary += ", ";
if (successCount > 0 || failCount > 0) summary += theme.sep.dot;
}
if (successCount > 0) {
summary += theme.fg("success", `${successCount} succeeded`);
if (failCount > 0) summary += ", ";
if (failCount > 0) summary += theme.sep.dot;
}
if (failCount > 0) {
summary += theme.fg("error", `${failCount} failed`);
@@ -2384,7 +2384,7 @@ export function renderWebFetchResult(
? uiTheme.styledSymbol("status.warning", "warning")
: uiTheme.styledSymbol("status.success", "success");
const expandHint = expanded ? "" : uiTheme.fg("dim", " (Ctrl+O to expand)");
let text = `${statusIcon} ${uiTheme.fg("toolTitle", "Web Fetch")} ${uiTheme.fg("accent", `(${domain})`)}${uiTheme.sep.dot}${uiTheme.fg("dim", details.method)}${expandHint}`;
let text = `${statusIcon} ${uiTheme.fg("accent", `(${domain})`)}${uiTheme.sep.dot}${uiTheme.fg("dim", details.method)}${expandHint}`;
// Get content text
const contentText = result.content[0]?.text ?? "";
@@ -20,10 +20,11 @@ import type { CustomTool, CustomToolContext, RenderResultOptions } from "../../c
import { callExaTool, findApiKey as findExaKey, formatSearchResults, isSearchResponse } from "../exa/mcp-client";
import { renderExaCall, renderExaResult } from "../exa/render";
import type { ExaRenderDetails } from "../exa/types";
import { formatAge } from "../render-utils";
import { searchAnthropic } from "./providers/anthropic";
import { searchExa } from "./providers/exa";
import { findApiKey as findPerplexityKey, searchPerplexity } from "./providers/perplexity";
import { formatAge, renderWebSearchCall, renderWebSearchResult, type WebSearchRenderDetails } from "./render";
import { renderWebSearchCall, renderWebSearchResult, type WebSearchRenderDetails } from "./render";
import type { WebSearchProvider, WebSearchResponse } from "./types";
/** Web search parameters schema */
@@ -31,8 +32,8 @@ export const webSearchSchema = Type.Object({
// Common
query: Type.String({ description: "Search query" }),
provider: Type.Optional(
Type.Union([Type.Literal("exa"), Type.Literal("anthropic"), Type.Literal("perplexity")], {
description: "Search provider (auto-detected if omitted based on API keys)",
Type.Union([Type.Literal("auto"), Type.Literal("exa"), Type.Literal("anthropic"), Type.Literal("perplexity")], {
description: "Search provider (auto-detected if omitted or set to auto)",
}),
),
num_results: Type.Optional(Type.Number({ description: "Maximum number of results to return" })),
@@ -81,7 +82,7 @@ export const webSearchSchema = Type.Object({
export type WebSearchParams = {
query: string;
provider?: "exa" | "anthropic" | "perplexity";
provider?: "auto" | "exa" | "anthropic" | "perplexity";
num_results?: number;
// Anthropic
system_prompt?: string;
@@ -198,7 +199,7 @@ async function executeWebSearch(
params: WebSearchParams,
): Promise<{ content: Array<{ type: "text"; text: string }>; details: WebSearchRenderDetails }> {
try {
const provider = params.provider ?? (await detectProvider());
const provider = params.provider && params.provider !== "auto" ? params.provider : await detectProvider();
let response: WebSearchResponse;
if (provider === "exa") {
@@ -8,72 +8,39 @@ import type { Component } from "@oh-my-pi/pi-tui";
import { Text } from "@oh-my-pi/pi-tui";
import type { Theme } from "../../../modes/interactive/theme/theme";
import type { RenderResultOptions } from "../../custom-tools/types";
import {
formatAge,
formatCount,
formatExpandHint,
formatMoreItems,
getDomain,
getPreviewLines,
getStyledStatusIcon,
PREVIEW_LIMITS,
TRUNCATE_LENGTHS,
truncate,
} from "../render-utils";
import type { WebSearchResponse } from "./types";
/** Truncate text to max length with ellipsis */
export function truncate(text: string, maxLen: number, ellipsis: string): string {
if (text.length <= maxLen) return text;
const sliceLen = Math.max(0, maxLen - ellipsis.length);
return `${text.slice(0, sliceLen)}${ellipsis}`;
}
/** Extract domain from URL */
export function getDomain(url: string): string {
try {
const u = new URL(url);
return u.hostname.replace(/^www\./, "");
} catch {
return url;
}
}
/** Format age string from seconds */
export function formatAge(ageSeconds: number | null | undefined): string {
if (!ageSeconds) return "";
const mins = Math.floor(ageSeconds / 60);
const hours = Math.floor(mins / 60);
const days = Math.floor(hours / 24);
const weeks = Math.floor(days / 7);
const months = Math.floor(days / 30);
if (months > 0) return `${months}mo ago`;
if (weeks > 0) return `${weeks}w ago`;
if (days > 0) return `${days}d ago`;
if (hours > 0) return `${hours}h ago`;
if (mins > 0) return `${mins}m ago`;
return "just now";
}
/** Get first N lines of text as preview */
export function getPreviewLines(text: string, maxLines: number, maxLineLen: number, ellipsis: string): string[] {
const lines = text.split("\n").filter((l) => l.trim());
return lines.slice(0, maxLines).map((l) => truncate(l.trim(), maxLineLen, ellipsis));
}
const MAX_COLLAPSED_ANSWER_LINES = 3;
const MAX_EXPANDED_ANSWER_LINES = 12;
const MAX_ANSWER_LINE_LEN = 110;
const MAX_COLLAPSED_ANSWER_LINES = PREVIEW_LIMITS.COLLAPSED_LINES;
const MAX_EXPANDED_ANSWER_LINES = PREVIEW_LIMITS.EXPANDED_LINES;
const MAX_ANSWER_LINE_LEN = TRUNCATE_LENGTHS.LINE;
const MAX_SNIPPET_LINES = 2;
const MAX_SNIPPET_LINE_LEN = 110;
const MAX_SNIPPET_LINE_LEN = TRUNCATE_LENGTHS.LINE;
const MAX_RELATED_QUESTIONS = 6;
const MAX_QUERY_PREVIEW = 2;
const MAX_QUERY_LEN = 90;
const MAX_REQUEST_ID_LEN = 36;
function formatCount(label: string, count: number): string {
const safeCount = Number.isFinite(count) ? count : 0;
return `${safeCount} ${label}${safeCount === 1 ? "" : "s"}`;
}
function renderFallbackText(contentText: string, expanded: boolean, theme: Theme): Component {
const lines = contentText.split("\n").filter((line) => line.trim());
const maxLines = expanded ? lines.length : 6;
const displayLines = lines.slice(0, maxLines).map((line) => truncate(line.trim(), 110, theme.format.ellipsis));
const remaining = lines.length - displayLines.length;
const headerIcon = theme.fg("warning", theme.status.warning);
const expandHint = expanded ? "" : theme.fg("dim", " (Ctrl+O to expand)");
let text = `${headerIcon} ${theme.fg("toolTitle", "Web Search")}${expandHint}`;
const headerIcon = getStyledStatusIcon("warning", theme);
const expandHint = formatExpandHint(expanded, remaining > 0, theme);
let text = `${headerIcon} ${theme.fg("dim", "Response")}${expandHint}`;
if (displayLines.length === 0) {
text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg("muted", "No response data")}`;
@@ -87,10 +54,7 @@ function renderFallbackText(contentText: string, expanded: boolean, theme: Theme
}
if (!expanded && remaining > 0) {
text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg(
"muted",
`${theme.format.ellipsis} ${remaining} more line${remaining === 1 ? "" : "s"}`,
)}`;
text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg("muted", formatMoreItems(remaining, "line", theme))}`;
}
return new Text(text, 0, 0);
@@ -134,23 +98,6 @@ export function renderWebSearchResult(
: [];
const provider = response.provider;
// Build header: status icon Web Search (provider) · counts
const providerLabel =
provider === "anthropic"
? "Anthropic"
: provider === "perplexity"
? "Perplexity"
: provider === "exa"
? "Exa"
: "Unknown";
const headerIcon =
sourceCount > 0 ? theme.fg("success", theme.status.success) : theme.fg("warning", theme.status.warning);
const expandHint = expanded ? "" : theme.fg("dim", " (Ctrl+O to expand)");
let text = `${headerIcon} ${theme.fg("toolTitle", "Web Search")} ${theme.fg("dim", `(${providerLabel})`)}${theme.sep.dot}${theme.fg(
"dim",
formatCount("source", sourceCount),
)}${expandHint}`;
// Get answer text
const answerText = typeof response.answer === "string" ? response.answer.trim() : "";
const contentText = answerText || rawText;
@@ -160,36 +107,57 @@ export function renderWebSearchResult(
? getPreviewLines(contentText, answerLimit, MAX_ANSWER_LINE_LEN, theme.format.ellipsis)
: [];
// Build header: status icon Web Search (provider) · counts
const providerLabel =
provider === "anthropic"
? "Anthropic"
: provider === "perplexity"
? "Perplexity"
: provider === "exa"
? "Exa"
: "Unknown";
const headerIcon = getStyledStatusIcon(sourceCount > 0 ? "success" : "warning", theme);
const hasMore =
totalAnswerLines > answerPreview.length ||
sourceCount > 0 ||
citationCount > 0 ||
relatedCount > 0 ||
searchQueries.length > 0;
const expandHint = formatExpandHint(expanded, hasMore, theme);
let text = `${headerIcon} ${theme.fg("dim", `(${providerLabel})`)}${theme.sep.dot}${theme.fg(
"dim",
formatCount("source", sourceCount),
)}${expandHint}`;
if (!expanded) {
const answerTitle = `${theme.fg("accent", theme.status.info)} ${theme.fg("accent", "Answer")}`;
text += `\n ${theme.fg("dim", theme.tree.vertical)} ${answerTitle}`;
if (answerPreview.length === 0) {
text += `\n ${theme.fg("dim", theme.tree.vertical)} ${theme.fg("dim", `${theme.tree.hook} `)}${theme.fg(
"muted",
"No answer text returned",
)}`;
} else {
for (const line of answerPreview) {
text += `\n ${theme.fg("dim", theme.tree.vertical)} ${theme.fg("dim", `${theme.tree.hook} `)}${theme.fg(
"dim",
line,
)}`;
}
}
text += `\n ${theme.fg("dim", theme.tree.branch)} ${answerTitle}`;
const remaining = totalAnswerLines - answerPreview.length;
const allLines: Array<{ text: string; style: "dim" | "muted" }> = [];
if (answerPreview.length === 0) {
allLines.push({ text: "No answer text returned", style: "muted" });
} else {
for (const line of answerPreview) {
allLines.push({ text: line, style: "dim" });
}
}
if (remaining > 0) {
text += `\n ${theme.fg("dim", theme.tree.vertical)} ${theme.fg("dim", `${theme.tree.hook} `)}${theme.fg(
"muted",
`${theme.format.ellipsis} ${remaining} more line${remaining === 1 ? "" : "s"}`,
)}`;
allLines.push({ text: formatMoreItems(remaining, "line", theme), style: "muted" });
}
for (let i = 0; i < allLines.length; i++) {
const { text: lineText, style } = allLines[i];
const isLastLine = i === allLines.length - 1;
const lineBranch = isLastLine ? theme.tree.last : theme.tree.branch;
text += `\n ${theme.fg("dim", theme.tree.vertical)} ${theme.fg("dim", lineBranch)} ${theme.fg(style, lineText)}`;
}
const summary = [
formatCount("source", sourceCount),
formatCount("citation", citationCount),
formatCount("related", relatedCount),
formatCount("related question", relatedCount),
].join(theme.sep.dot);
text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg("muted", summary)}`;
return new Text(text, 0, 0);
@@ -201,9 +169,7 @@ export function renderWebSearchResult(
);
const remainingAnswer = totalAnswerLines - answerPreview.length;
if (remainingAnswer > 0) {
answerSectionLines.push(
theme.fg("muted", `${theme.format.ellipsis} ${remainingAnswer} more line${remainingAnswer === 1 ? "" : "s"}`),
);
answerSectionLines.push(theme.fg("muted", formatMoreItems(remainingAnswer, "line", theme)));
}
const sourceLines: string[] = [];
@@ -263,14 +229,7 @@ export function renderWebSearchResult(
relatedLines.push(theme.fg("muted", `${theme.format.dash} ${related[i]}`));
}
if (relatedCount > maxRelated) {
relatedLines.push(
theme.fg(
"muted",
`${theme.format.ellipsis} ${relatedCount - maxRelated} more question${
relatedCount - maxRelated === 1 ? "" : "s"
}`,
),
);
relatedLines.push(theme.fg("muted", formatMoreItems(relatedCount - maxRelated, "question", theme)));
}
}
@@ -311,36 +270,29 @@ export function renderWebSearchResult(
metaLines.push(theme.fg("muted", `${theme.format.dash} ${truncate(q, MAX_QUERY_LEN, theme.format.ellipsis)}`));
}
if (searchQueries.length > MAX_QUERY_PREVIEW) {
metaLines.push(
theme.fg(
"muted",
`${theme.format.ellipsis} ${searchQueries.length - MAX_QUERY_PREVIEW} more query${
searchQueries.length - MAX_QUERY_PREVIEW === 1 ? "" : "s"
}`,
),
);
metaLines.push(theme.fg("muted", formatMoreItems(searchQueries.length - MAX_QUERY_PREVIEW, "query", theme)));
}
}
const sections: Array<{ title: string; icon: string; lines: string[] }> = [
{
title: "Answer",
icon: theme.fg("accent", theme.status.info),
icon: getStyledStatusIcon("info", theme),
lines: answerSectionLines,
},
{
title: "Sources",
icon: sourceCount > 0 ? theme.fg("success", theme.status.success) : theme.fg("warning", theme.status.warning),
icon: getStyledStatusIcon(sourceCount > 0 ? "success" : "warning", theme),
lines: sourceLines,
},
{
title: "Related",
icon: relatedCount > 0 ? theme.fg("accent", theme.status.info) : theme.fg("warning", theme.status.warning),
icon: getStyledStatusIcon(relatedCount > 0 ? "info" : "warning", theme),
lines: relatedLines,
},
{
title: "Meta",
icon: theme.fg("accent", theme.status.info),
icon: getStyledStatusIcon("info", theme),
lines: metaLines,
},
];
@@ -349,11 +301,14 @@ export function renderWebSearchResult(
const section = sections[i];
const isLast = i === sections.length - 1;
const branch = isLast ? theme.tree.last : theme.tree.branch;
const indent = isLast ? " " : theme.tree.vertical;
const indent = isLast ? " " : `${theme.tree.vertical} `;
text += `\n ${theme.fg("dim", branch)} ${section.icon} ${theme.fg("accent", section.title)}`;
for (const line of section.lines) {
text += `\n ${theme.fg("dim", indent)} ${theme.fg("dim", `${theme.tree.hook} `)}${line}`;
for (let j = 0; j < section.lines.length; j++) {
const line = section.lines[j];
const isLastLine = j === section.lines.length - 1;
const lineBranch = isLastLine ? theme.tree.last : theme.tree.branch;
text += `\n ${theme.fg("dim", indent)}${theme.fg("dim", lineBranch)} ${line}`;
}
}
@@ -333,9 +333,7 @@ export class StatusLineComponent implements Component {
? separatorDef.endCaps.right
: separatorDef.endCaps.left
: "";
const capPrefix = separatorDef.endCaps?.useBgAsFg
? bgAnsi.replace("\x1b[48;", "\x1b[38;")
: bgAnsi + sepAnsi;
const capPrefix = separatorDef.endCaps?.useBgAsFg ? bgAnsi.replace("\x1b[48;", "\x1b[38;") : bgAnsi + sepAnsi;
const capText = cap ? `${capPrefix}${cap}\x1b[0m` : "";
let content = bgAnsi + fgAnsi;
@@ -96,11 +96,11 @@ import sandstone from "./sandstone.json" with { type: "json" };
import titanium from "./titanium.json" with { type: "json" };
export const defaultThemes = {
"alabaster": alabaster,
"amethyst": amethyst,
"anthracite": anthracite,
"basalt": basalt,
"birch": birch,
alabaster: alabaster,
amethyst: amethyst,
anthracite: anthracite,
basalt: basalt,
birch: birch,
"dark-abyss": dark_abyss,
"dark-arctic": dark_arctic,
"dark-aurora": dark_aurora,
@@ -143,7 +143,7 @@ export const defaultThemes = {
"dark-tundra": dark_tundra,
"dark-twilight": dark_twilight,
"dark-volcanic": dark_volcanic,
"graphite": graphite,
graphite: graphite,
"light-arctic": light_arctic,
"light-aurora-day": light_aurora_day,
"light-canyon": light_canyon,
@@ -182,14 +182,14 @@ export const defaultThemes = {
"light-tokyo-night": light_tokyo_night,
"light-wetland": light_wetland,
"light-zenith": light_zenith,
"limestone": limestone,
"mahogany": mahogany,
"marble": marble,
"obsidian": obsidian,
"onyx": onyx,
"pearl": pearl,
"porcelain": porcelain,
"quartz": quartz,
"sandstone": sandstone,
"titanium": titanium,
limestone: limestone,
mahogany: mahogany,
marble: marble,
obsidian: obsidian,
onyx: onyx,
pearl: pearl,
porcelain: porcelain,
quartz: quartz,
sandstone: sandstone,
titanium: titanium,
};