Skip to content

Commit e83290b

Browse files
committed
feat: ✨ Give the terminal color and ansi escape rendering
1 parent 2d3bdbe commit e83290b

File tree

5 files changed

+358
-25
lines changed

5 files changed

+358
-25
lines changed

core/tools/implementations/runTerminalCommand.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,16 @@ import {
1010

1111
const asyncExec = util.promisify(childProcess.exec);
1212

13+
// Add color-supporting environment variables
14+
const getColorEnv = () => ({
15+
...process.env,
16+
FORCE_COLOR: "1",
17+
COLORTERM: "truecolor",
18+
TERM: "xterm-256color",
19+
CLICOLOR: "1",
20+
CLICOLOR_FORCE: "1",
21+
});
22+
1323
const ENABLED_FOR_REMOTES = [
1424
"",
1525
"local",
@@ -56,10 +66,11 @@ export const runTerminalCommandImpl: ToolImpl = async (args, extras) => {
5666
}
5767
}
5868

59-
// Use spawn instead of exec to get streaming output
69+
// Use spawn with color environment
6070
const childProc = childProcess.spawn(args.command, {
6171
cwd,
6272
shell: true,
73+
env: getColorEnv(), // Add enhanced environment for colors
6374
});
6475

6576
childProc.stdout?.on("data", (data) => {
@@ -200,7 +211,11 @@ export const runTerminalCommandImpl: ToolImpl = async (args, extras) => {
200211
if (waitForCompletion) {
201212
// Standard execution, waiting for completion
202213
try {
203-
const output = await asyncExec(args.command, { cwd });
214+
// Use color environment for exec as well
215+
const output = await asyncExec(args.command, {
216+
cwd,
217+
env: getColorEnv(),
218+
});
204219
const status = "Command completed";
205220
return [
206221
{
@@ -225,10 +240,11 @@ export const runTerminalCommandImpl: ToolImpl = async (args, extras) => {
225240
// For non-streaming but also not waiting for completion, use spawn
226241
// but don't attach any listeners other than error
227242
try {
228-
// Use spawn instead of exec but don't wait
243+
// Use spawn with color environment
229244
const childProc = childProcess.spawn(args.command, {
230245
cwd,
231246
shell: true,
247+
env: getColorEnv(), // Add color environment
232248
// Detach the process so it's not tied to the parent
233249
detached: true,
234250
// Redirect to /dev/null equivalent (works cross-platform)

gui/package-lock.json

Lines changed: 15 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

gui/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,12 @@
3434
"@tiptap/starter-kit": "^2.1.13",
3535
"@tiptap/suggestion": "^2.1.13",
3636
"@types/uuid": "^10.0.0",
37+
"anser": "^2.3.2",
3738
"clsx": "^2.1.1",
3839
"core": "file:../core",
3940
"dompurify": "^3.0.6",
4041
"downshift": "^7.6.0",
42+
"escape-carriage": "^1.3.1",
4143
"lodash": "^4.17.21",
4244
"lowlight": "^3.3.0",
4345
"minisearch": "^7.0.2",
Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
import Anser, { AnserJsonEntry } from "anser";
2+
import { escapeCarriageReturn } from "escape-carriage";
3+
import * as React from "react";
4+
import styled from "styled-components";
5+
import {
6+
defaultBorderRadius,
7+
vscBackground,
8+
vscEditorBackground,
9+
vscForeground,
10+
} from "../../components";
11+
import { getFontSize } from "../../util";
12+
13+
const AnsiSpan = styled.span<{
14+
bg?: string;
15+
fg?: string;
16+
decoration?: string;
17+
}>`
18+
${({ bg }) => bg && `background-color: rgb(${bg});`}
19+
${({ fg }) => fg && `color: rgb(${fg});`}
20+
${({ decoration }) => {
21+
switch (decoration) {
22+
case "bold":
23+
return "font-weight: bold;";
24+
case "dim":
25+
return "opacity: 0.5;";
26+
case "italic":
27+
return "font-style: italic;";
28+
case "hidden":
29+
return "visibility: hidden;";
30+
case "strikethrough":
31+
return "text-decoration: line-through;";
32+
case "underline":
33+
return "text-decoration: underline;";
34+
case "blink":
35+
return "text-decoration: blink;";
36+
default:
37+
return "";
38+
}
39+
}}
40+
`;
41+
42+
const AnsiLink = styled.a`
43+
color: var(--vscode-textLink-foreground, #3794ff);
44+
text-decoration: none;
45+
&:hover {
46+
text-decoration: underline;
47+
}
48+
`;
49+
50+
// Using the same styled component structure as StyledMarkdown
51+
const StyledAnsi = styled.div<{
52+
fontSize?: number;
53+
whiteSpace: string;
54+
bgColor: string;
55+
}>`
56+
pre {
57+
white-space: ${(props) => props.whiteSpace};
58+
background-color: ${vscEditorBackground};
59+
border-radius: ${defaultBorderRadius};
60+
border: 1px solid
61+
var(--vscode-editorWidget-border, rgba(127, 127, 127, 0.3));
62+
max-width: calc(100vw - 24px);
63+
overflow-x: scroll;
64+
overflow-y: hidden;
65+
padding: 8px;
66+
}
67+
68+
code {
69+
span.line:empty {
70+
display: none;
71+
}
72+
word-wrap: break-word;
73+
border-radius: ${defaultBorderRadius};
74+
background-color: ${vscEditorBackground};
75+
font-size: ${getFontSize() - 2}px;
76+
font-family: var(--vscode-editor-font-family);
77+
}
78+
79+
code:not(pre > code) {
80+
font-family: var(--vscode-editor-font-family);
81+
color: var(--vscode-input-placeholderForeground);
82+
}
83+
84+
background-color: ${(props) => props.bgColor};
85+
font-family:
86+
var(--vscode-font-family),
87+
system-ui,
88+
-apple-system,
89+
BlinkMacSystemFont,
90+
"Segoe UI",
91+
Roboto,
92+
Oxygen,
93+
Ubuntu,
94+
Cantarell,
95+
"Open Sans",
96+
"Helvetica Neue",
97+
sans-serif;
98+
font-size: ${(props) => props.fontSize || getFontSize()}px;
99+
padding-left: 8px;
100+
padding-right: 8px;
101+
color: ${vscForeground};
102+
line-height: 1.5;
103+
104+
> *:last-child {
105+
margin-bottom: 0;
106+
}
107+
`;
108+
109+
/**
110+
* Converts ANSI strings into JSON output.
111+
* @name ansiToJSON
112+
* @function
113+
* @param {String} input The input string.
114+
* @param {boolean} use_classes If `true`, HTML classes will be appended
115+
* to the HTML output.
116+
* @return {Array} The parsed input.
117+
*/
118+
function ansiToJSON(
119+
input: string,
120+
use_classes: boolean = false,
121+
): AnserJsonEntry[] {
122+
input = escapeCarriageReturn(fixBackspace(input));
123+
return Anser.ansiToJson(input, {
124+
json: true,
125+
remove_empty: true,
126+
use_classes,
127+
});
128+
}
129+
130+
/**
131+
* Create a class string.
132+
* @name createClass
133+
* @function
134+
* @param {AnserJsonEntry} bundle
135+
* @return {String} class name(s)
136+
*/
137+
function createClass(bundle: AnserJsonEntry): string | null {
138+
let classNames: string = "";
139+
140+
if (bundle.bg) {
141+
classNames += `${bundle.bg}-bg `;
142+
}
143+
if (bundle.fg) {
144+
classNames += `${bundle.fg}-fg `;
145+
}
146+
if (bundle.decoration) {
147+
classNames += `ansi-${bundle.decoration} `;
148+
}
149+
150+
if (classNames === "") {
151+
return null;
152+
}
153+
154+
classNames = classNames.substring(0, classNames.length - 1);
155+
return classNames;
156+
}
157+
158+
/**
159+
* Converts an Anser bundle into a React Node.
160+
* @param linkify whether links should be converting into clickable anchor tags.
161+
* @param useClasses should render the span with a class instead of style.
162+
* @param bundle Anser output.
163+
* @param key
164+
*/
165+
166+
function convertBundleIntoReact(
167+
linkify: boolean,
168+
useClasses: boolean,
169+
bundle: AnserJsonEntry,
170+
key: number,
171+
): JSX.Element {
172+
const className = useClasses ? createClass(bundle) : null;
173+
// Convert bundle.decoration to string or undefined (not null) to match the prop type
174+
const decorationProp = bundle.decoration
175+
? String(bundle.decoration)
176+
: undefined;
177+
178+
if (!linkify) {
179+
return (
180+
<AnsiSpan
181+
key={key}
182+
className={className || undefined}
183+
bg={useClasses ? undefined : bundle.bg}
184+
fg={useClasses ? undefined : bundle.fg}
185+
decoration={decorationProp}
186+
>
187+
{bundle.content}
188+
</AnsiSpan>
189+
);
190+
}
191+
192+
const content: React.ReactNode[] = [];
193+
const linkRegex =
194+
/(\s|^)(https?:\/\/(?:www\.|(?!www))[^\s.]+\.[^\s]{2,}|www\.[^\s]+\.[^\s]{2,})/g;
195+
196+
let index = 0;
197+
let match: RegExpExecArray | null;
198+
while ((match = linkRegex.exec(bundle.content)) !== null) {
199+
const [, pre, url] = match;
200+
201+
const startIndex = match.index + pre.length;
202+
if (startIndex > index) {
203+
content.push(bundle.content.substring(index, startIndex));
204+
}
205+
206+
// Make sure the href we generate from the link is fully qualified. We assume http
207+
// if it starts with a www because many sites don't support https
208+
const href = url.startsWith("www.") ? `http://${url}` : url;
209+
210+
content.push(
211+
<AnsiLink key={index} href={href} target="_blank">
212+
{url}
213+
</AnsiLink>,
214+
);
215+
216+
index = linkRegex.lastIndex;
217+
}
218+
219+
if (index < bundle.content.length) {
220+
content.push(bundle.content.substring(index));
221+
}
222+
223+
return (
224+
<AnsiSpan
225+
key={key}
226+
className={className || undefined}
227+
bg={useClasses ? undefined : bundle.bg}
228+
fg={useClasses ? undefined : bundle.fg}
229+
decoration={decorationProp}
230+
>
231+
{content}
232+
</AnsiSpan>
233+
);
234+
}
235+
236+
declare interface Props {
237+
children?: string;
238+
linkify?: boolean;
239+
className?: string;
240+
useClasses?: boolean;
241+
}
242+
243+
export default function Ansi(props: Props): JSX.Element {
244+
const { className, useClasses, children, linkify } = props;
245+
246+
// Create the ANSI content
247+
const ansiContent = ansiToJSON(children ?? "", useClasses ?? false).map(
248+
(bundle, i) =>
249+
convertBundleIntoReact(linkify ?? false, useClasses ?? false, bundle, i),
250+
);
251+
252+
return (
253+
<StyledAnsi
254+
contentEditable="false"
255+
fontSize={getFontSize()}
256+
whiteSpace="pre-wrap"
257+
bgColor={vscBackground}
258+
>
259+
<pre>
260+
<code className={className}>{ansiContent}</code>
261+
</pre>
262+
</StyledAnsi>
263+
);
264+
}
265+
266+
// This is copied from the Jupyter Classic source code
267+
// notebook/static/base/js/utils.js to handle \b in a way
268+
// that is **compatible with Jupyter classic**. One can
269+
// argue that this behavior is questionable:
270+
// https://stackoverflow.com/questions/55440152/multiple-b-doesnt-work-as-expected-in-jupyter#
271+
function fixBackspace(txt: string) {
272+
let tmp = txt;
273+
do {
274+
txt = tmp;
275+
// Cancel out anything-but-newline followed by backspace
276+
tmp = txt.replace(/[^\n]\x08/gm, "");
277+
} while (tmp.length < txt.length);
278+
return txt;
279+
}

0 commit comments

Comments
 (0)