Skip to content

Commit a142d2b

Browse files
authored
Merge pull request #14 from capsulerun/feature/v0.1.0/shell
feat: add shell package with React-based terminal UI
2 parents 29a186a + 1526e71 commit a142d2b

27 files changed

Lines changed: 1819 additions & 92 deletions

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,4 +147,6 @@ vite.config.ts.timestamp-*
147147
.capsule/
148148
.capsule/*
149149

150+
examples/
151+
150152

package.json

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,17 @@
77
"packageManager": "pnpm@10.21.0",
88
"type": "commonjs",
99
"scripts": {
10-
"test": "echo \"Error: no test specified\" && exit 1"
10+
"test": "echo \"Error: no test specified\" && exit 1",
11+
"bash-wasm-shell": "pnpm --filter bash-wasm-shell bash-wasm-shell"
1112
},
1213
"dependencies": {
13-
"@capsule-run/cli": "^0.8.8",
14-
"@capsule-run/sdk": "^0.8.8"
14+
"@capsule-run/cli": "^0.8.9",
15+
"@capsule-run/sdk": "^0.8.9"
1516
},
1617
"devDependencies": {
18+
"@types/node": "^25.6.0",
1719
"esbuild": "^0.28.0",
1820
"typescript": "^6.0.2",
19-
"@types/node": "^25.2.3",
2021
"vitest": "^4.1.4"
2122
}
2223
}

packages/bash-types/src/command.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export interface CommandResult {
1818
stdout: string;
1919
stderr: string;
2020
exitCode: number;
21+
durationMs?: number;
2122
diff?: {
2223
created: string[];
2324
modified: string[];
@@ -26,6 +27,7 @@ export interface CommandResult {
2627
state?: {
2728
cwd: string;
2829
env: Record<string, string>;
30+
exitCode: number;
2931
}
3032
};
3133

packages/bash-types/src/runtime.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ export interface BaseRuntime {
77
*/
88
hostWorkspace: string;
99

10+
/**
11+
* Preload the runtime
12+
*/
13+
preload(state: State, name?: string): Promise<void>;
14+
1015
/**
1116
* Execute a code
1217
*/
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
{
2+
"name": "bash-wasm-shell",
3+
"version": "0.1.0",
4+
"description": "Interactive shell UI for capsule-run/bash",
5+
"type": "module",
6+
"private": true,
7+
"scripts": {
8+
"bash-wasm-shell": "tsx src/index.tsx"
9+
},
10+
"packageManager": "pnpm@10.21.0",
11+
"dependencies": {
12+
"@capsule-run/bash": "workspace:*",
13+
"@capsule-run/bash-types": "workspace:*",
14+
"@capsule-run/bash-wasm": "workspace:*",
15+
"ink": "^7.0.0",
16+
"ink-image": "^2.0.0",
17+
"ink-spinner": "^5.0.0",
18+
"ink-text-input": "^6.0.0",
19+
"react": "^19.2.5"
20+
},
21+
"devDependencies": {
22+
"@types/node": "^25.6.0",
23+
"@types/react": "^19.2.14",
24+
"tsx": "^4.0.0"
25+
}
26+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { Bash } from '@capsule-run/bash';
2+
import { WasmRuntime } from '@capsule-run/bash-wasm';
3+
4+
export const bash = new Bash({
5+
runtime: new WasmRuntime(),
6+
});
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import { Box, Text } from 'ink';
2+
import type { HistoryEntry } from '../hooks/useShell.js';
3+
4+
type Props = {
5+
entry: HistoryEntry;
6+
};
7+
8+
type DiffItem = {
9+
filenames?: string[];
10+
content?: string;
11+
type: 'created' | 'modified' | 'deleted' | 'stdout' | 'stderr' | 'exit';
12+
exitCode?: number;
13+
durationMs?: number;
14+
};
15+
16+
const TYPE_STYLE: Record<DiffItem['type'], { label: string; color: string }> = {
17+
created: { label: 'Created', color: 'green' },
18+
modified: { label: 'Modified', color: 'yellow' },
19+
deleted: { label: 'Deleted', color: 'red' },
20+
stdout: { label: 'Stdout', color: 'white' },
21+
stderr: { label: 'Stderr', color: 'red' },
22+
exit: { label: 'Exit', color: 'green' },
23+
};
24+
25+
function formatDuration(ms: number): string {
26+
if (ms < 1000) return `${ms}ms`;
27+
return `${(ms / 1000).toFixed(1)}s`;
28+
}
29+
30+
function groupDiffItems(items: DiffItem[]): DiffItem[] {
31+
return items.reduce<DiffItem[]>((acc, item) => {
32+
const isDiff = item.type === 'created' || item.type === 'modified' || item.type === 'deleted';
33+
const prev = acc[acc.length - 1];
34+
35+
if (isDiff && prev?.type === item.type) {
36+
prev.filenames = [...(prev.filenames ?? []), ...(item.filenames ?? [])];
37+
return acc;
38+
}
39+
40+
acc.push({ ...item });
41+
return acc;
42+
}, []);
43+
}
44+
45+
function contentHeight(item: DiffItem): number {
46+
const isDiff = item.type === 'created' || item.type === 'modified' || item.type === 'deleted';
47+
if (isDiff) return (item.filenames?.length ?? 0) + 1;
48+
if (item.type === 'exit') return 1;
49+
return (item.content?.split('\n').length ?? 0) + 1;
50+
}
51+
52+
export function DiffTimeline({ entry }: Props) {
53+
const rawItems: DiffItem[] = [
54+
...(entry.diff?.created || []).map(f => ({ filenames: [f], type: 'created' as const })),
55+
...(entry.diff?.modified || []).map(f => ({ filenames: [f], type: 'modified' as const })),
56+
...(entry.diff?.deleted || []).map(f => ({ filenames: [f], type: 'deleted' as const })),
57+
...(entry.stdout ? [{ content: entry.stdout.trimEnd(), type: 'stdout' as const }] : []),
58+
...(entry.stderr ? [{ content: entry.stderr.trimEnd(), type: 'stderr' as const }] : []),
59+
{ type: 'exit' as const, exitCode: entry.exitCode, durationMs: entry.durationMs },
60+
];
61+
62+
const items = groupDiffItems(rawItems);
63+
64+
return (
65+
<Box flexDirection="column" paddingLeft={2} marginBottom={1} marginTop={1}>
66+
{items.map((item, index) => {
67+
const isLast = index === items.length - 1;
68+
const style = TYPE_STYLE[item.type];
69+
const isDiff = item.type === 'created' || item.type === 'modified' || item.type === 'deleted';
70+
const isExit = item.type === 'exit';
71+
const exitColor = isExit ? (item.exitCode === 0 ? 'green' : 'red') : style.color;
72+
const pipeCount = contentHeight(item) + (isLast ? -1 : 1);
73+
74+
return (
75+
<Box key={index} flexDirection="row">
76+
<Box flexDirection="column" marginRight={2} alignItems="center">
77+
<Text color={isExit ? exitColor : style.color}></Text>
78+
{pipeCount > 0 && (
79+
<Box flexDirection="column">
80+
{Array.from({ length: pipeCount }).map((_, i) => (
81+
<Text key={i} dimColor></Text>
82+
))}
83+
</Box>
84+
)}
85+
</Box>
86+
87+
<Box flexDirection="column">
88+
{isExit ? (
89+
<Box gap={1}>
90+
<Text color={exitColor}>exit {item.exitCode}</Text>
91+
</Box>
92+
) : isDiff ? (
93+
<Box flexDirection="column" gap={1}>
94+
<Text bold>{style.label}</Text>
95+
<Box flexDirection="column">
96+
{item.filenames?.map((filename, i) => (
97+
<Text key={i}>
98+
{item.type === 'created' ? <Text color={style.color}>[+]</Text> : item.type === 'modified' ? <Text color={style.color}>[~]</Text> : <Text color={style.color}>[-]</Text>}
99+
{filename}
100+
</Text>
101+
))}
102+
</Box>
103+
</Box>
104+
) : (
105+
<Box flexDirection="column">
106+
<Text bold dimColor={item.type === 'stderr'}>{style.label}</Text>
107+
<Box marginTop={1}>
108+
<Text dimColor={item.type === 'stderr'}>{item.content}</Text>
109+
</Box>
110+
</Box>
111+
)}
112+
</Box>
113+
</Box>
114+
);
115+
})}
116+
</Box>
117+
);
118+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { Box, Spacer, Text, useStdout } from 'ink';
2+
import Spinner from 'ink-spinner';
3+
4+
type Props = {
5+
jsReady: boolean;
6+
pythonReady: boolean;
7+
};
8+
9+
function SandboxStatus({ ready, label }: { ready: boolean; label: string }) {
10+
return (
11+
<Box gap={1}>
12+
{ready ? (
13+
<>
14+
<Text color="green"></Text>
15+
<Text dimColor>{label}</Text>
16+
</>
17+
) : (
18+
<>
19+
<Text color="yellow"><Spinner type="dots" /></Text>
20+
<Text dimColor>{label}</Text>
21+
</>
22+
)}
23+
</Box>
24+
);
25+
}
26+
27+
export function Header({ jsReady, pythonReady }: Props) {
28+
const { stdout } = useStdout();
29+
30+
return (
31+
<Box
32+
flexDirection="column"
33+
borderStyle="round"
34+
borderColor="#444444"
35+
width={stdout?.columns || 80}
36+
paddingX={2}
37+
paddingY={1}
38+
marginBottom={1}
39+
>
40+
<Box justifyContent="space-between" width="100%">
41+
42+
<Box flexDirection="column">
43+
<Text bold>⬢ Capsule Bash</Text>
44+
<Text dimColor>Environnement v0.1.0</Text>
45+
</Box>
46+
47+
<Box flexDirection="column" alignItems="flex-end">
48+
<SandboxStatus ready={jsReady} label="Sandbox JS" />
49+
<SandboxStatus ready={pythonReady} label="Sandbox Python" />
50+
</Box>
51+
</Box>
52+
</Box>
53+
);
54+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { Box, Text } from 'ink';
2+
import type { HistoryEntry } from '../hooks/useShell.js';
3+
import { DiffTimeline } from './DiffTimeline.js';
4+
5+
type Props = {
6+
entry: HistoryEntry;
7+
};
8+
9+
export function OutputLine({ entry }: Props) {
10+
const exitColor = entry.exitCode === 0 ? 'green' : 'red';
11+
12+
return (
13+
<Box flexDirection="column" marginLeft={1}>
14+
<Box gap={1}>
15+
<Text bold dimColor>{entry.state?.cwd != '/' ? entry.state?.cwd.slice(1) : entry.state?.cwd}</Text>
16+
<Text bold color={exitColor}></Text>
17+
<Text>{entry.command}</Text>
18+
</Box>
19+
<DiffTimeline entry={entry} />
20+
</Box>
21+
);
22+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { useState } from 'react';
2+
import { Box, Text, useInput } from 'ink';
3+
import TextInput from 'ink-text-input';
4+
import Spinner from 'ink-spinner';
5+
6+
type Props = {
7+
cwd: string;
8+
lastExitCode: number;
9+
running: boolean;
10+
runningCommand: string;
11+
onSubmit: (command: string) => void;
12+
history: string[];
13+
};
14+
15+
export function Prompt({ cwd, lastExitCode, running, runningCommand, onSubmit, history }: Props) {
16+
const [input, setInput] = useState('');
17+
const [historyIndex, setHistoryIndex] = useState(-1);
18+
19+
const handleSubmit = (value: string) => {
20+
onSubmit(value);
21+
setInput('');
22+
setHistoryIndex(-1);
23+
};
24+
25+
useInput((_input, key) => {
26+
if (running) return;
27+
if (key.upArrow) {
28+
const nextIndex = Math.min(historyIndex + 1, history.length - 1);
29+
setHistoryIndex(nextIndex);
30+
setInput(history[history.length - 1 - nextIndex] ?? '');
31+
} else if (key.downArrow) {
32+
const nextIndex = historyIndex - 1;
33+
if (nextIndex < 0) {
34+
setHistoryIndex(-1);
35+
setInput('');
36+
} else {
37+
setHistoryIndex(nextIndex);
38+
setInput(history[history.length - 1 - nextIndex] ?? '');
39+
}
40+
}
41+
});
42+
43+
const promptColor = lastExitCode === 0 ? 'green' : 'red';
44+
45+
return (
46+
<Box gap={1} marginLeft={1}>
47+
<Text bold>{cwd != '/' ? cwd.slice(1) : cwd}</Text>
48+
<Text bold color={promptColor}></Text>
49+
50+
<TextInput
51+
value={input}
52+
onChange={(val) => { setInput(val); setHistoryIndex(-1); }}
53+
onSubmit={handleSubmit}
54+
/>
55+
</Box>
56+
);
57+
}

0 commit comments

Comments
 (0)