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:
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user