utils.ts
tools/BashTool/utils.ts
224
Lines
7207
Bytes
9
Exports
12
Imports
10
Keywords
What this is
This page documents one file from the repository and includes its full source so you can read it without leaving the docs site.
Beginner explanation
This file is part of the tool layer, which means it describes actions the system can perform for the user or model.
How it is used
Start from the exports list and related files. Those are the easiest clues for where this file fits into the system.
Expert explanation
Architecturally, this file intersects with tool-system. It contains 224 lines, 12 detected imports, and 9 detected exports.
Important relationships
- tools/BashTool/BashTool.tsx
- tools/BashTool/BashToolResultMessage.tsx
- tools/BashTool/UI.tsx
- tools/BashTool/bashCommandHelpers.ts
- tools/BashTool/bashPermissions.ts
- tools/BashTool/bashSecurity.ts
- tools/BashTool/commandSemantics.ts
- tools/BashTool/commentLabel.ts
- components/ManagedSettingsSecurityDialog/utils.ts
- components/PromptInput/utils.ts
- components/Spinner/utils.ts
- components/TrustDialog/utils.ts
Detected exports
stripEmptyLinesisImageOutputparseDataUribuildImageToolResultresizeShellImageOutputformatOutputstdErrAppendShellResetMessageresetCwdIfOutsideProjectcreateContentSummary
Keywords
contentdatalinesbase64imageblocktextparsedutilsmatch
Detected imports
@anthropic-ai/sdk/resources/index.mjsfs/promisessrc/bootstrap/state.jssrc/services/analytics/index.jssrc/Tool.jssrc/utils/cwd.jssrc/utils/permissions/filesystem.jssrc/utils/Shell.js../../utils/envUtils.js../../utils/imageResizer.js../../utils/shell/outputLimits.js../../utils/stringUtils.js
Source notes
This page embeds the full file contents. Small or leaf files are still indexed honestly instead of being over-explained.
Full source
import type {
Base64ImageSource,
ContentBlockParam,
ToolResultBlockParam,
} from '@anthropic-ai/sdk/resources/index.mjs'
import { readFile, stat } from 'fs/promises'
import { getOriginalCwd } from 'src/bootstrap/state.js'
import { logEvent } from 'src/services/analytics/index.js'
import type { ToolPermissionContext } from 'src/Tool.js'
import { getCwd } from 'src/utils/cwd.js'
import { pathInAllowedWorkingPath } from 'src/utils/permissions/filesystem.js'
import { setCwd } from 'src/utils/Shell.js'
import { shouldMaintainProjectWorkingDir } from '../../utils/envUtils.js'
import { maybeResizeAndDownsampleImageBuffer } from '../../utils/imageResizer.js'
import { getMaxOutputLength } from '../../utils/shell/outputLimits.js'
import { countCharInString, plural } from '../../utils/stringUtils.js'
/**
* Strips leading and trailing lines that contain only whitespace/newlines.
* Unlike trim(), this preserves whitespace within content lines and only removes
* completely empty lines from the beginning and end.
*/
export function stripEmptyLines(content: string): string {
const lines = content.split('\n')
// Find the first non-empty line
let startIndex = 0
while (startIndex < lines.length && lines[startIndex]?.trim() === '') {
startIndex++
}
// Find the last non-empty line
let endIndex = lines.length - 1
while (endIndex >= 0 && lines[endIndex]?.trim() === '') {
endIndex--
}
// If all lines are empty, return empty string
if (startIndex > endIndex) {
return ''
}
// Return the slice with non-empty lines
return lines.slice(startIndex, endIndex + 1).join('\n')
}
/**
* Check if content is a base64 encoded image data URL
*/
export function isImageOutput(content: string): boolean {
return /^data:image\/[a-z0-9.+_-]+;base64,/i.test(content)
}
const DATA_URI_RE = /^data:([^;]+);base64,(.+)$/
/**
* Parse a data-URI string into its media type and base64 payload.
* Input is trimmed before matching.
*/
export function parseDataUri(
s: string,
): { mediaType: string; data: string } | null {
const match = s.trim().match(DATA_URI_RE)
if (!match || !match[1] || !match[2]) return null
return { mediaType: match[1], data: match[2] }
}
/**
* Build an image tool_result block from shell stdout containing a data URI.
* Returns null if parse fails so callers can fall through to text handling.
*/
export function buildImageToolResult(
stdout: string,
toolUseID: string,
): ToolResultBlockParam | null {
const parsed = parseDataUri(stdout)
if (!parsed) return null
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: [
{
type: 'image',
source: {
type: 'base64',
media_type: parsed.mediaType as Base64ImageSource['media_type'],
data: parsed.data,
},
},
],
}
}
// Cap file reads to 20 MB — any image data URI larger than this is
// well beyond what the API accepts (5 MB base64) and would OOM if read
// into memory.
const MAX_IMAGE_FILE_SIZE = 20 * 1024 * 1024
/**
* Resize image output from a shell tool. stdout is capped at
* getMaxOutputLength() when read back from the shell output file — if the
* full output spilled to disk, re-read it from there, since truncated base64
* would decode to a corrupt image that either throws here or gets rejected by
* the API. Caps dimensions too: compressImageBuffer only checks byte size, so
* a small-but-high-DPI PNG (e.g. matplotlib at dpi=300) sails through at full
* resolution and poisons many-image requests (CC-304).
*
* Returns the re-encoded data URI on success, or null if the source didn't
* parse as a data URI (caller decides whether to flip isImage).
*/
export async function resizeShellImageOutput(
stdout: string,
outputFilePath: string | undefined,
outputFileSize: number | undefined,
): Promise<string | null> {
let source = stdout
if (outputFilePath) {
const size = outputFileSize ?? (await stat(outputFilePath)).size
if (size > MAX_IMAGE_FILE_SIZE) return null
source = await readFile(outputFilePath, 'utf8')
}
const parsed = parseDataUri(source)
if (!parsed) return null
const buf = Buffer.from(parsed.data, 'base64')
const ext = parsed.mediaType.split('/')[1] || 'png'
const resized = await maybeResizeAndDownsampleImageBuffer(
buf,
buf.length,
ext,
)
return `data:image/${resized.mediaType};base64,${resized.buffer.toString('base64')}`
}
export function formatOutput(content: string): {
totalLines: number
truncatedContent: string
isImage?: boolean
} {
const isImage = isImageOutput(content)
if (isImage) {
return {
totalLines: 1,
truncatedContent: content,
isImage,
}
}
const maxOutputLength = getMaxOutputLength()
if (content.length <= maxOutputLength) {
return {
totalLines: countCharInString(content, '\n') + 1,
truncatedContent: content,
isImage,
}
}
const truncatedPart = content.slice(0, maxOutputLength)
const remainingLines = countCharInString(content, '\n', maxOutputLength) + 1
const truncated = `${truncatedPart}\n\n... [${remainingLines} lines truncated] ...`
return {
totalLines: countCharInString(content, '\n') + 1,
truncatedContent: truncated,
isImage,
}
}
export const stdErrAppendShellResetMessage = (stderr: string): string =>
`${stderr.trim()}\nShell cwd was reset to ${getOriginalCwd()}`
export function resetCwdIfOutsideProject(
toolPermissionContext: ToolPermissionContext,
): boolean {
const cwd = getCwd()
const originalCwd = getOriginalCwd()
const shouldMaintain = shouldMaintainProjectWorkingDir()
if (
shouldMaintain ||
// Fast path: originalCwd is unconditionally in allWorkingDirectories
// (filesystem.ts), so when cwd hasn't moved, pathInAllowedWorkingPath is
// trivially true — skip its syscalls for the no-cd common case.
(cwd !== originalCwd &&
!pathInAllowedWorkingPath(cwd, toolPermissionContext))
) {
// Reset to original directory if maintaining project dir OR outside allowed working directory
setCwd(originalCwd)
if (!shouldMaintain) {
logEvent('tengu_bash_tool_reset_to_original_dir', {})
return true
}
}
return false
}
/**
* Creates a human-readable summary of structured content blocks.
* Used to display MCP results with images and text in the UI.
*/
export function createContentSummary(content: ContentBlockParam[]): string {
const parts: string[] = []
let textCount = 0
let imageCount = 0
for (const block of content) {
if (block.type === 'image') {
imageCount++
} else if (block.type === 'text' && 'text' in block) {
textCount++
// Include first 200 chars of text blocks for context
const preview = block.text.slice(0, 200)
parts.push(preview + (block.text.length > 200 ? '...' : ''))
}
}
const summary: string[] = []
if (imageCount > 0) {
summary.push(`[${imageCount} ${plural(imageCount, 'image')}]`)
}
if (textCount > 0) {
summary.push(`[${textCount} text ${plural(textCount, 'block')}]`)
}
return `MCP Result: ${summary.join(', ')}${parts.length > 0 ? '\n\n' + parts.join('\n\n') : ''}`
}