protoagent 0.1.13 → 0.1.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -4
- package/dist/App.js +77 -442
- package/dist/agentic-loop/errors.js +198 -0
- package/dist/agentic-loop/executor.js +108 -0
- package/dist/agentic-loop/stream.js +109 -0
- package/dist/agentic-loop.js +67 -593
- package/dist/components/ApprovalPrompt.js +18 -0
- package/dist/components/CommandFilter.js +19 -0
- package/dist/components/InlineSetup.js +33 -0
- package/dist/components/UsageDisplay.js +10 -0
- package/dist/config.js +52 -51
- package/dist/hooks/useAgentEventHandler.js +356 -0
- package/dist/mcp.js +3 -0
- package/dist/runtime-config.js +64 -33
- package/dist/skills.js +3 -1
- package/dist/sub-agent.js +11 -16
- package/dist/tools/bash.js +37 -11
- package/dist/tools/edit-file.js +8 -49
- package/dist/tools/read-file.js +3 -66
- package/dist/tools/search-files.js +70 -12
- package/dist/tools/webfetch.js +77 -62
- package/dist/tools/write-file.js +39 -3
- package/dist/utils/approval.js +2 -0
- package/dist/utils/compactor.js +2 -1
- package/dist/utils/cost-tracker.js +5 -2
- package/dist/utils/format-message.js +13 -0
- package/dist/utils/logger.js +16 -3
- package/dist/utils/path-suggestions.js +74 -0
- package/dist/utils/path-validation.js +2 -5
- package/dist/utils/tool-display.js +53 -0
- package/package.json +11 -4
- package/dist/components/CollapsibleBox.js +0 -27
- package/dist/components/ConfigDialog.js +0 -42
- package/dist/components/ConsolidatedToolMessage.js +0 -34
- package/dist/components/FormattedMessage.js +0 -170
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path suggestions utility — Find similar paths when a file isn't found.
|
|
3
|
+
*
|
|
4
|
+
* Used by read_file and edit_file to suggest alternatives when
|
|
5
|
+
* the requested path doesn't exist (helps recover from typos).
|
|
6
|
+
*/
|
|
7
|
+
import fs from 'node:fs/promises';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import leven from 'leven';
|
|
10
|
+
import { getWorkingDirectory } from './path-validation.js';
|
|
11
|
+
const MAX_DEPTH = 6;
|
|
12
|
+
const MAX_ENTRIES = 200;
|
|
13
|
+
const MAX_CANDIDATES = 50;
|
|
14
|
+
const MAX_SUGGESTIONS = 3;
|
|
15
|
+
/**
|
|
16
|
+
* Collect all file paths recursively up to MAX_DEPTH.
|
|
17
|
+
*/
|
|
18
|
+
async function collectAllPaths(cwd) {
|
|
19
|
+
const paths = [];
|
|
20
|
+
const skipDirs = new Set(['node_modules', '.git', 'dist', 'build', 'coverage']);
|
|
21
|
+
async function walk(dir, currentPath) {
|
|
22
|
+
if (paths.length >= MAX_CANDIDATES)
|
|
23
|
+
return;
|
|
24
|
+
let entries;
|
|
25
|
+
try {
|
|
26
|
+
const dirEntries = await fs.readdir(dir, { withFileTypes: true });
|
|
27
|
+
entries = dirEntries
|
|
28
|
+
.filter(e => !skipDirs.has(e.name))
|
|
29
|
+
.slice(0, MAX_ENTRIES)
|
|
30
|
+
.map(e => e.name);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
for (const entry of entries) {
|
|
36
|
+
if (paths.length >= MAX_CANDIDATES)
|
|
37
|
+
return;
|
|
38
|
+
const entryPath = currentPath ? `${currentPath}/${entry}` : entry;
|
|
39
|
+
const fullPath = path.join(dir, entry);
|
|
40
|
+
paths.push(entryPath);
|
|
41
|
+
// Continue walking deeper
|
|
42
|
+
try {
|
|
43
|
+
const stat = await fs.stat(fullPath);
|
|
44
|
+
if (stat.isDirectory() && entryPath.split('/').length < MAX_DEPTH) {
|
|
45
|
+
await walk(fullPath, entryPath);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// skip
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
await walk(cwd, '');
|
|
54
|
+
return paths;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Find similar paths when a requested file doesn't exist.
|
|
58
|
+
* Uses Levenshtein distance to find the closest matches.
|
|
59
|
+
*/
|
|
60
|
+
export async function findSimilarPaths(requestedPath) {
|
|
61
|
+
const cwd = getWorkingDirectory();
|
|
62
|
+
// Collect all available paths
|
|
63
|
+
const allPaths = await collectAllPaths(cwd);
|
|
64
|
+
// Calculate Levenshtein distance for each path
|
|
65
|
+
const scored = allPaths.map(candidatePath => ({
|
|
66
|
+
path: candidatePath,
|
|
67
|
+
distance: leven(requestedPath.toLowerCase(), candidatePath.toLowerCase()),
|
|
68
|
+
}));
|
|
69
|
+
// Sort by distance (lower is better) and take top suggestions
|
|
70
|
+
scored.sort((a, b) => a.distance - b.distance);
|
|
71
|
+
return scored
|
|
72
|
+
.slice(0, MAX_SUGGESTIONS)
|
|
73
|
+
.map(s => s.path);
|
|
74
|
+
}
|
|
@@ -7,14 +7,11 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import fs from 'node:fs/promises';
|
|
9
9
|
import path from 'node:path';
|
|
10
|
+
import isPathInside from 'is-path-inside';
|
|
10
11
|
const workingDirectory = process.cwd();
|
|
11
12
|
let allowedRoots = [];
|
|
12
|
-
function isWithinRoot(targetPath, rootPath) {
|
|
13
|
-
const relative = path.relative(rootPath, targetPath);
|
|
14
|
-
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
|
15
|
-
}
|
|
16
13
|
function isAllowedPath(targetPath) {
|
|
17
|
-
return
|
|
14
|
+
return isPathInside(targetPath, workingDirectory) || allowedRoots.some((root) => isPathInside(targetPath, root));
|
|
18
15
|
}
|
|
19
16
|
export async function setAllowedPathRoots(roots) {
|
|
20
17
|
const normalizedRoots = await Promise.all(roots.map(async (root) => {
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Extract the most meaningful detail from tool args based on tool type
|
|
2
|
+
export function extractToolDetail(tool, args) {
|
|
3
|
+
switch (tool) {
|
|
4
|
+
case 'read_file':
|
|
5
|
+
case 'write_file':
|
|
6
|
+
case 'edit_file':
|
|
7
|
+
return typeof args.file_path === 'string' ? args.file_path : '';
|
|
8
|
+
case 'list_directory':
|
|
9
|
+
return typeof args.directory_path === 'string' ? args.directory_path : '(current)';
|
|
10
|
+
case 'search_files':
|
|
11
|
+
return typeof args.search_term === 'string' ? `"${args.search_term}"` : '';
|
|
12
|
+
case 'bash':
|
|
13
|
+
if (typeof args.command !== 'string')
|
|
14
|
+
return '';
|
|
15
|
+
const parts = args.command.split(/\s+/);
|
|
16
|
+
return parts.slice(0, 3).join(' ') + (parts.length > 3 ? '...' : '');
|
|
17
|
+
case 'todo_write':
|
|
18
|
+
return Array.isArray(args.todos) ? `${args.todos.length} task(s)` : '';
|
|
19
|
+
case 'todo_read':
|
|
20
|
+
return 'read';
|
|
21
|
+
case 'webfetch':
|
|
22
|
+
return typeof args.url === 'string' ? new URL(args.url).hostname : '';
|
|
23
|
+
case 'sub_agent':
|
|
24
|
+
return 'nested task...';
|
|
25
|
+
default: {
|
|
26
|
+
// Fallback: first string argument, truncated to 30 chars
|
|
27
|
+
const firstEntry = Object.entries(args).find(([, v]) => typeof v === 'string');
|
|
28
|
+
if (!firstEntry)
|
|
29
|
+
return '';
|
|
30
|
+
const value = String(firstEntry[1]);
|
|
31
|
+
return value.length > 30 ? value.slice(0, 30) + '...' : value;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
// Format sub-agent activity: "Sub-agent read_file: src/App.tsx"
|
|
36
|
+
export function formatSubAgentActivity(tool, args) {
|
|
37
|
+
if (!args || typeof args !== 'object') {
|
|
38
|
+
return `Sub-agent running ${tool}...`;
|
|
39
|
+
}
|
|
40
|
+
const detail = extractToolDetail(tool, args);
|
|
41
|
+
if (!detail) {
|
|
42
|
+
return `Sub-agent running ${tool}...`;
|
|
43
|
+
}
|
|
44
|
+
return `Sub-agent ${tool.replace(/_/g, ' ')}: ${detail}`;
|
|
45
|
+
}
|
|
46
|
+
// Format tool activity: "read_file src/App.tsx"
|
|
47
|
+
export function formatToolActivity(tool, args) {
|
|
48
|
+
if (!args || typeof args !== 'object') {
|
|
49
|
+
return tool;
|
|
50
|
+
}
|
|
51
|
+
const detail = extractToolDetail(tool, args);
|
|
52
|
+
return detail ? `${tool} ${detail}` : tool;
|
|
53
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "protoagent",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.15",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"files": [
|
|
6
6
|
"dist",
|
|
@@ -16,7 +16,9 @@
|
|
|
16
16
|
"build": "npm run clean && tsc && node -e \"const fs=require('node:fs'); if (fs.existsSync('dist/cli.js')) fs.chmodSync('dist/cli.js', 0o755)\"",
|
|
17
17
|
"build:watch": "tsc --watch",
|
|
18
18
|
"prepack": "npm run build",
|
|
19
|
-
"docs:dev": "
|
|
19
|
+
"docs:dev": "concurrently \"npm run docs:dev:worker\" \"npm run docs:dev:docs\" --kill-others",
|
|
20
|
+
"docs:dev:worker": "cd docs/worker && npm exec wrangler -- dev --port 8787 --ip 0.0.0.0",
|
|
21
|
+
"docs:dev:docs": "vitepress dev docs --port 5173 --host",
|
|
20
22
|
"docs:build": "vitepress build docs",
|
|
21
23
|
"docs:preview": "vitepress preview docs"
|
|
22
24
|
},
|
|
@@ -46,25 +48,30 @@
|
|
|
46
48
|
"@inkjs/ui": "^2.0.0",
|
|
47
49
|
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
48
50
|
"commander": "^14.0.1",
|
|
51
|
+
"diff": "^8.0.4",
|
|
49
52
|
"he": "^1.2.0",
|
|
50
53
|
"html-to-text": "^9.0.5",
|
|
51
54
|
"ink": "^6.8.0",
|
|
52
|
-
"
|
|
55
|
+
"is-path-inside": "^4.0.0",
|
|
53
56
|
"jsonc-parser": "^3.3.1",
|
|
57
|
+
"leven": "^4.1.0",
|
|
54
58
|
"openai": "^5.23.1",
|
|
55
59
|
"react": "^19.1.1",
|
|
60
|
+
"strip-ansi": "^7.2.0",
|
|
56
61
|
"turndown": "^7.2.2",
|
|
57
62
|
"yaml": "^2.8.2",
|
|
58
|
-
"
|
|
63
|
+
"zod": "^3.25.76"
|
|
59
64
|
},
|
|
60
65
|
"devDependencies": {
|
|
61
66
|
"@eslint/js": "^9.36.0",
|
|
62
67
|
"@tailwindcss/postcss": "^4.1.18",
|
|
68
|
+
"@types/diff": "^7.0.2",
|
|
63
69
|
"@types/he": "^1.2.3",
|
|
64
70
|
"@types/html-to-text": "^9.0.4",
|
|
65
71
|
"@types/node": "^24.5.2",
|
|
66
72
|
"@types/react": "^19.1.15",
|
|
67
73
|
"@types/turndown": "^5.0.6",
|
|
74
|
+
"concurrently": "^9.1.2",
|
|
68
75
|
"eslint": "^9.36.0",
|
|
69
76
|
"ink-testing-library": "^4.0.0",
|
|
70
77
|
"tailwindcss": "^4.0.0",
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { Text } from 'ink';
|
|
3
|
-
import { LeftBar } from './LeftBar.js';
|
|
4
|
-
export const CollapsibleBox = ({ title, content, titleColor, dimColor = false, maxPreviewLines = 3, maxPreviewChars = 500, expanded = false, marginBottom = 0, }) => {
|
|
5
|
-
const lines = content.split('\n');
|
|
6
|
-
const isTooManyLines = lines.length > maxPreviewLines;
|
|
7
|
-
const isTooManyChars = content.length > maxPreviewChars;
|
|
8
|
-
const isLong = isTooManyLines || isTooManyChars;
|
|
9
|
-
// If content is short, always show it
|
|
10
|
-
if (!isLong) {
|
|
11
|
-
return (_jsxs(LeftBar, { color: titleColor ?? 'white', marginBottom: marginBottom, children: [_jsx(Text, { color: titleColor, dimColor: dimColor, bold: true, children: title }), _jsx(Text, { dimColor: dimColor, children: content })] }));
|
|
12
|
-
}
|
|
13
|
-
// For long content, show preview or full content
|
|
14
|
-
let preview;
|
|
15
|
-
if (expanded) {
|
|
16
|
-
preview = content;
|
|
17
|
-
}
|
|
18
|
-
else {
|
|
19
|
-
// Truncate by lines first, then by characters
|
|
20
|
-
const linesTruncated = lines.slice(0, maxPreviewLines).join('\n');
|
|
21
|
-
preview = linesTruncated.length > maxPreviewChars
|
|
22
|
-
? linesTruncated.slice(0, maxPreviewChars)
|
|
23
|
-
: linesTruncated;
|
|
24
|
-
}
|
|
25
|
-
const hasMore = !expanded;
|
|
26
|
-
return (_jsxs(LeftBar, { color: titleColor ?? 'white', marginBottom: marginBottom, children: [_jsxs(Text, { color: titleColor, dimColor: dimColor, bold: true, children: [expanded ? '▼' : '▶', " ", title] }), _jsx(Text, { dimColor: dimColor, children: preview }), hasMore && _jsx(Text, { dimColor: true, children: "... (use /expand to see all)" })] }));
|
|
27
|
-
};
|
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
/**
|
|
3
|
-
* ConfigDialog — Modal-like dialog for changing config mid-conversation
|
|
4
|
-
*
|
|
5
|
-
* Allows users to update provider, model, or API key without losing chat history.
|
|
6
|
-
*/
|
|
7
|
-
import { useState } from 'react';
|
|
8
|
-
import { Box, Text } from 'ink';
|
|
9
|
-
import { PasswordInput, Select } from '@inkjs/ui';
|
|
10
|
-
import { getAllProviders, getProvider } from '../providers.js';
|
|
11
|
-
import { resolveApiKey } from '../config.js';
|
|
12
|
-
export const ConfigDialog = ({ currentConfig, onComplete, onCancel, }) => {
|
|
13
|
-
const [step, setStep] = useState('select_provider');
|
|
14
|
-
const [selectedProviderId, setSelectedProviderId] = useState(currentConfig.provider);
|
|
15
|
-
const [selectedModelId, setSelectedModelId] = useState(currentConfig.model);
|
|
16
|
-
const providerItems = getAllProviders().flatMap((provider) => provider.models.map((model) => ({
|
|
17
|
-
label: `${provider.name} - ${model.name}`,
|
|
18
|
-
value: `${provider.id}:::${model.id}`,
|
|
19
|
-
})));
|
|
20
|
-
const currentProvider = getProvider(currentConfig.provider);
|
|
21
|
-
// Provider selection step
|
|
22
|
-
if (step === 'select_provider') {
|
|
23
|
-
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, borderStyle: "round", borderColor: "green", paddingX: 1, children: [_jsx(Text, { color: "green", bold: true, children: "Change Configuration" }), _jsxs(Text, { dimColor: true, children: ["Current: ", currentProvider?.name, " / ", currentConfig.model] }), _jsx(Text, { dimColor: true, children: "Select a new provider and model:" }), _jsx(Box, { marginTop: 1, children: _jsx(Select, { options: providerItems.map((item) => ({ value: item.value, label: item.label })), onChange: (value) => {
|
|
24
|
-
const [providerId, modelId] = value.split(':::');
|
|
25
|
-
setSelectedProviderId(providerId);
|
|
26
|
-
setSelectedModelId(modelId);
|
|
27
|
-
setStep('enter_api_key');
|
|
28
|
-
} }) })] }));
|
|
29
|
-
}
|
|
30
|
-
// API key entry step
|
|
31
|
-
const provider = getProvider(selectedProviderId);
|
|
32
|
-
const hasResolvedAuth = Boolean(resolveApiKey({ provider: selectedProviderId, apiKey: undefined }));
|
|
33
|
-
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, borderStyle: "round", borderColor: "green", paddingX: 1, children: [_jsx(Text, { color: "green", bold: true, children: "Confirm Configuration" }), _jsxs(Text, { dimColor: true, children: ["Provider: ", provider?.name, " / ", selectedModelId] }), _jsx(Text, { children: hasResolvedAuth ? 'Optional API key (leave empty to keep resolved auth):' : 'Enter your API key:' }), _jsx(PasswordInput, { placeholder: `Paste your ${provider?.apiKeyEnvVar || 'API'} key`, onSubmit: (value) => {
|
|
34
|
-
const finalApiKey = value.trim().length > 0 ? value.trim() : currentConfig.apiKey;
|
|
35
|
-
const newConfig = {
|
|
36
|
-
provider: selectedProviderId,
|
|
37
|
-
model: selectedModelId,
|
|
38
|
-
...(finalApiKey?.trim() ? { apiKey: finalApiKey.trim() } : {}),
|
|
39
|
-
};
|
|
40
|
-
onComplete(newConfig);
|
|
41
|
-
} })] }));
|
|
42
|
-
};
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import { Box, Text } from 'ink';
|
|
3
|
-
import { FormattedMessage } from './FormattedMessage.js';
|
|
4
|
-
import { LeftBar } from './LeftBar.js';
|
|
5
|
-
export const ConsolidatedToolMessage = ({ toolCalls, toolResults, expanded = false, }) => {
|
|
6
|
-
const toolNames = toolCalls.map((toolCall) => toolCall.name);
|
|
7
|
-
const title = `Called: ${toolNames.join(', ')}`;
|
|
8
|
-
const containsTodoTool = toolCalls.some((toolCall) => toolCall.name === 'todo_read' || toolCall.name === 'todo_write');
|
|
9
|
-
const titleColor = containsTodoTool ? 'green' : 'cyan';
|
|
10
|
-
const isExpanded = expanded || containsTodoTool;
|
|
11
|
-
if (isExpanded) {
|
|
12
|
-
return (_jsxs(LeftBar, { color: titleColor, children: [_jsxs(Text, { color: titleColor, bold: true, children: ["\u25BC ", title] }), toolCalls.map((toolCall, idx) => {
|
|
13
|
-
const result = toolResults.get(toolCall.id);
|
|
14
|
-
if (!result)
|
|
15
|
-
return null;
|
|
16
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "cyan", bold: true, children: ["[", result.name, "]:"] }), _jsx(FormattedMessage, { content: result.content })] }, idx));
|
|
17
|
-
})] }));
|
|
18
|
-
}
|
|
19
|
-
const compactLines = toolCalls.flatMap((toolCall) => {
|
|
20
|
-
const result = toolResults.get(toolCall.id);
|
|
21
|
-
if (!result)
|
|
22
|
-
return [];
|
|
23
|
-
const compactContent = result.content
|
|
24
|
-
.replace(/\s+/g, ' ')
|
|
25
|
-
.trim();
|
|
26
|
-
return [`[${result.name}] ${compactContent}`];
|
|
27
|
-
});
|
|
28
|
-
const compactPreview = compactLines.join(' | ');
|
|
29
|
-
const previewLimit = 180;
|
|
30
|
-
const preview = compactPreview.length > previewLimit
|
|
31
|
-
? `${compactPreview.slice(0, previewLimit).trimEnd()}... (use /expand)`
|
|
32
|
-
: compactPreview;
|
|
33
|
-
return (_jsxs(LeftBar, { color: "white", children: [_jsxs(Text, { color: titleColor, dimColor: true, bold: true, children: ["\u25B6 ", title] }), _jsx(Text, { dimColor: true, children: preview })] }));
|
|
34
|
-
};
|
|
@@ -1,170 +0,0 @@
|
|
|
1
|
-
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import { Box, Text } from 'ink';
|
|
3
|
-
import { renderFormattedText } from '../utils/format-message.js';
|
|
4
|
-
import { LeftBar } from './LeftBar.js';
|
|
5
|
-
export const DEFERRED_TABLE_PLACEHOLDER = 'table loading';
|
|
6
|
-
const graphemeSegmenter = typeof Intl !== 'undefined' && 'Segmenter' in Intl
|
|
7
|
-
? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
|
|
8
|
-
: null;
|
|
9
|
-
const COMBINING_MARK_PATTERN = /\p{Mark}/u;
|
|
10
|
-
const ZERO_WIDTH_PATTERN = /[\u200B-\u200D\uFE0E\uFE0F]/u;
|
|
11
|
-
const DOUBLE_WIDTH_PATTERN = /[\u1100-\u115F\u2329\u232A\u2E80-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE10-\uFE19\uFE30-\uFE6F\uFF00-\uFF60\uFFE0-\uFFE6\u{1F300}-\u{1FAFF}\u{1F900}-\u{1F9FF}\u{1F1E6}-\u{1F1FF}]/u;
|
|
12
|
-
function splitGraphemes(text) {
|
|
13
|
-
if (!text)
|
|
14
|
-
return [];
|
|
15
|
-
if (graphemeSegmenter) {
|
|
16
|
-
return Array.from(graphemeSegmenter.segment(text), (segment) => segment.segment);
|
|
17
|
-
}
|
|
18
|
-
return Array.from(text);
|
|
19
|
-
}
|
|
20
|
-
function getGraphemeWidth(grapheme) {
|
|
21
|
-
if (!grapheme)
|
|
22
|
-
return 0;
|
|
23
|
-
if (ZERO_WIDTH_PATTERN.test(grapheme))
|
|
24
|
-
return 0;
|
|
25
|
-
if (COMBINING_MARK_PATTERN.test(grapheme))
|
|
26
|
-
return 0;
|
|
27
|
-
if (/^[\u0000-\u001F\u007F-\u009F]$/.test(grapheme))
|
|
28
|
-
return 0;
|
|
29
|
-
if (DOUBLE_WIDTH_PATTERN.test(grapheme))
|
|
30
|
-
return 2;
|
|
31
|
-
return 1;
|
|
32
|
-
}
|
|
33
|
-
function getTextWidth(text) {
|
|
34
|
-
return splitGraphemes(text).reduce((width, grapheme) => width + getGraphemeWidth(grapheme), 0);
|
|
35
|
-
}
|
|
36
|
-
function padToWidth(text, width) {
|
|
37
|
-
const padding = Math.max(0, width - getTextWidth(text));
|
|
38
|
-
return text + ' '.repeat(padding);
|
|
39
|
-
}
|
|
40
|
-
function parseMarkdownTableToRows(markdown) {
|
|
41
|
-
const lines = markdown.trim().split('\n');
|
|
42
|
-
if (lines.length < 3)
|
|
43
|
-
return null;
|
|
44
|
-
const parseRow = (row) => row.split('|')
|
|
45
|
-
.map((cell) => cell.trim())
|
|
46
|
-
.filter((cell, index, array) => {
|
|
47
|
-
if (index === 0 && cell === '')
|
|
48
|
-
return false;
|
|
49
|
-
if (index === array.length - 1 && cell === '')
|
|
50
|
-
return false;
|
|
51
|
-
return true;
|
|
52
|
-
});
|
|
53
|
-
const header = parseRow(lines[0]);
|
|
54
|
-
const separator = parseRow(lines[1]);
|
|
55
|
-
if (header.length === 0 || separator.length === 0)
|
|
56
|
-
return null;
|
|
57
|
-
if (!separator.every((cell) => /^:?-{3,}:?$/.test(cell.replace(/\s+/g, ''))))
|
|
58
|
-
return null;
|
|
59
|
-
const rows = lines.slice(2).map(parseRow);
|
|
60
|
-
return [header, ...rows];
|
|
61
|
-
}
|
|
62
|
-
function renderPreformattedTable(markdown) {
|
|
63
|
-
const rows = parseMarkdownTableToRows(markdown);
|
|
64
|
-
if (!rows || rows.length === 0) {
|
|
65
|
-
return markdown.trim();
|
|
66
|
-
}
|
|
67
|
-
const columnCount = Math.max(...rows.map((row) => row.length));
|
|
68
|
-
const normalizedRows = rows.map((row) => Array.from({ length: columnCount }, (_, index) => row[index] ?? ''));
|
|
69
|
-
const widths = Array.from({ length: columnCount }, (_, index) => Math.max(...normalizedRows.map((row) => getTextWidth(row[index]))));
|
|
70
|
-
const formatRow = (row) => row
|
|
71
|
-
.map((cell, index) => padToWidth(cell, widths[index]))
|
|
72
|
-
.join(' ')
|
|
73
|
-
.trimEnd();
|
|
74
|
-
const header = formatRow(normalizedRows[0]);
|
|
75
|
-
const divider = widths.map((width) => '-'.repeat(width)).join(' ');
|
|
76
|
-
const body = normalizedRows.slice(1).map(formatRow);
|
|
77
|
-
return [header, divider, ...body].join('\n');
|
|
78
|
-
}
|
|
79
|
-
/**
|
|
80
|
-
* FormattedMessage component
|
|
81
|
-
*
|
|
82
|
-
* Parses a markdown string and renders:
|
|
83
|
-
* - Standard text with ANSI formatting
|
|
84
|
-
* - Markdown tables as preformatted monospace text
|
|
85
|
-
* - Code blocks (rendered in a box)
|
|
86
|
-
*/
|
|
87
|
-
export const FormattedMessage = ({ content, deferTables = false }) => {
|
|
88
|
-
if (!content)
|
|
89
|
-
return null;
|
|
90
|
-
const lines = content.split('\n');
|
|
91
|
-
const blocks = [];
|
|
92
|
-
let currentBlockContent = [];
|
|
93
|
-
let currentBlockType = 'text';
|
|
94
|
-
for (let i = 0; i < lines.length; i++) {
|
|
95
|
-
const line = lines[i];
|
|
96
|
-
const trimmedLine = line.trim();
|
|
97
|
-
// 1. Handle Code Blocks
|
|
98
|
-
if (currentBlockType === 'code') {
|
|
99
|
-
currentBlockContent.push(line);
|
|
100
|
-
// Check for end of code block
|
|
101
|
-
if (trimmedLine.startsWith('```')) {
|
|
102
|
-
blocks.push({ type: 'code', content: currentBlockContent.join('\n') });
|
|
103
|
-
currentBlockContent = [];
|
|
104
|
-
currentBlockType = 'text';
|
|
105
|
-
}
|
|
106
|
-
continue;
|
|
107
|
-
}
|
|
108
|
-
// Start Code Block
|
|
109
|
-
if (trimmedLine.startsWith('```')) {
|
|
110
|
-
// Finish pending text block
|
|
111
|
-
if (currentBlockContent.length > 0) {
|
|
112
|
-
blocks.push({ type: 'text', content: currentBlockContent.join('\n') });
|
|
113
|
-
}
|
|
114
|
-
currentBlockContent = [line];
|
|
115
|
-
currentBlockType = 'code';
|
|
116
|
-
continue;
|
|
117
|
-
}
|
|
118
|
-
// 2. Handle Tables
|
|
119
|
-
if (currentBlockType === 'table') {
|
|
120
|
-
if (trimmedLine.startsWith('|')) {
|
|
121
|
-
currentBlockContent.push(line);
|
|
122
|
-
continue;
|
|
123
|
-
}
|
|
124
|
-
else {
|
|
125
|
-
// End of table block found (line doesn't start with |)
|
|
126
|
-
blocks.push({ type: 'table', content: currentBlockContent.join('\n') });
|
|
127
|
-
// Reset to text and fall through to re-process this line
|
|
128
|
-
currentBlockContent = [];
|
|
129
|
-
currentBlockType = 'text';
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
// Start Table Block check
|
|
133
|
-
// A table start requires a pipe AND a subsequent separator line
|
|
134
|
-
const isTableStart = trimmedLine.startsWith('|');
|
|
135
|
-
const nextLine = lines[i + 1];
|
|
136
|
-
const isNextLineSeparator = nextLine && nextLine.trim().startsWith('|') && nextLine.includes('---');
|
|
137
|
-
if (isTableStart && isNextLineSeparator) {
|
|
138
|
-
// Finish pending text block
|
|
139
|
-
if (currentBlockContent.length > 0) {
|
|
140
|
-
blocks.push({ type: 'text', content: currentBlockContent.join('\n') });
|
|
141
|
-
}
|
|
142
|
-
currentBlockContent = [line];
|
|
143
|
-
currentBlockType = 'table';
|
|
144
|
-
continue;
|
|
145
|
-
}
|
|
146
|
-
// 3. Handle Text
|
|
147
|
-
currentBlockContent.push(line);
|
|
148
|
-
}
|
|
149
|
-
// Push final block
|
|
150
|
-
if (currentBlockContent.length > 0) {
|
|
151
|
-
blocks.push({ type: currentBlockType, content: currentBlockContent.join('\n') });
|
|
152
|
-
}
|
|
153
|
-
return (_jsx(Box, { flexDirection: "column", children: blocks.map((block, index) => {
|
|
154
|
-
if (block.type === 'table') {
|
|
155
|
-
if (!block.content.trim())
|
|
156
|
-
return null;
|
|
157
|
-
if (deferTables) {
|
|
158
|
-
return (_jsx(Box, { marginY: 1, children: _jsx(Text, { dimColor: true, children: DEFERRED_TABLE_PLACEHOLDER }) }, index));
|
|
159
|
-
}
|
|
160
|
-
return (_jsx(LeftBar, { color: "gray", marginTop: 1, marginBottom: 1, children: _jsx(Text, { children: renderPreformattedTable(block.content) }) }, index));
|
|
161
|
-
}
|
|
162
|
-
if (block.type === 'code') {
|
|
163
|
-
return (_jsx(LeftBar, { color: "gray", marginTop: 1, marginBottom: 1, children: _jsx(Text, { dimColor: true, children: block.content }) }, index));
|
|
164
|
-
}
|
|
165
|
-
// Text Block
|
|
166
|
-
if (!block.content.trim())
|
|
167
|
-
return null;
|
|
168
|
-
return (_jsx(Box, { marginBottom: 0, children: _jsx(Text, { children: renderFormattedText(block.content) }) }, index));
|
|
169
|
-
}) }));
|
|
170
|
-
};
|