Skip to content

Commit f94c8f4

Browse files
committed
refactor(tui): Port from Ink to OpenTUI for terminal rendering
OpenTUI comes with a lot more features out-the-box than Ink, most notably around mouse input and fullscreen display. By simply porting, the TUI has gained mouse-wheel scrolling for the transcript, auto- updating themes when light/dark modes are toggled in the environment, and built-in handling of rendering the app fullscreen.
1 parent 8adc65c commit f94c8f4

27 files changed

Lines changed: 586 additions & 968 deletions

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# compiled binary
2-
/agent
2+
/toolsmith
33

44
# dependencies (bun install)
55
node_modules

README.md

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
1-
<p align="center">
2-
<img src="logo.svg" alt="Agent Toolsmith" width="100%">
3-
</p>
1+
<div align="center">
2+
<img width="50%" alt="Agent Toolsmith logo" src="./logo.svg" />
3+
<h4>A general-purpose AI agent that writes its own tools</h4>
4+
</div>
45

5-
# Agent Toolsmith
6+
---
67

7-
A general-purpose AI agent that writes its own tools.
8-
9-
It starts with two built-in tools — `evolve` to write a tool and `inspect` to read one back — and writes the rest as it needs them. When it hits a task it can't do yet, it writes a new tool, saves it to disk and uses it right away. New tools persist across sessions, so the agent's capabilities grow the more you use it.
8+
Starting with just two built-ins — `evolve` for writing and `inspect` for reading — when Toolsmith hits a task it can't do yet, it writes a new tool and uses it right away. New tools persist across sessions, so the agent's capabilities grow the more you use it.
109

1110
> [!CAUTION]
12-
> This is a personal experiment for didactic purposes only. It's rough around the edges and not battle-tested in any way. Tool code runs in-process with your privileges — there is no sandbox (for now). The system prompt instructs the model not to write tools that delete data, leak secrets, or make irreversible changes unless asked, but of course this cannot be trusted.
11+
> This is a personal experiment for didactic purposes only. It's rough around the edges and not battle-tested in any way. **Tool code runs in-process with your privileges** — there is no sandbox for now. The system prompt instructs the model not to write tools that delete data, leak secrets, or make irreversible changes unless asked, but of course this cannot be trusted.
1312
1413
## How It Works
1514

build.ts

Lines changed: 1 addition & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,4 @@
1-
// `bun build --compile` directly doesn't work because Ink's reconciler
2-
// dynamically imports `./devtools.js` (gated on `process.env.DEV === 'true'`),
3-
// and that file has a static `import devtools from 'react-devtools-core'`. Bun's
4-
// bundler eagerly follows dynamic imports into the bundle graph, so the
5-
// unresolved `react-devtools-core` fails the build even though the branch is
6-
// dead in prod. `--external` would fix it for normal bundling but not for
7-
// `--compile` (compiled binaries have no node_modules at runtime).
8-
//
9-
// Fix: stub `react-devtools-core` to an empty module so the eager resolve
10-
// succeeds. The stub is never reached at runtime as long as `DEV` is unset.
111
await Bun.build({
122
entrypoints: ["./src/index.ts"],
13-
compile: { outfile: "agent" },
14-
plugins: [
15-
{
16-
name: "stub-react-devtools-core",
17-
setup(builder) {
18-
builder.onResolve({ filter: /^react-devtools-core$/ }, () => ({
19-
path: "react-devtools-core",
20-
namespace: "stub",
21-
}));
22-
builder.onLoad({ filter: /.*/, namespace: "stub" }, () => ({
23-
contents: "export default {}",
24-
loader: "js",
25-
}));
26-
},
27-
},
28-
],
3+
compile: { outfile: "toolsmith" },
294
});

bun.lock

Lines changed: 33 additions & 172 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

logo.svg

Lines changed: 44 additions & 1 deletion
Loading

package.json

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,18 +14,15 @@
1414
},
1515
"dependencies": {
1616
"@anthropic-ai/sdk": "^0.96.0",
17-
"@byteland/ink-scroll-bar": "^1.0.0",
18-
"@inkjs/ui": "^2.0.0",
19-
"ink": "^7.1.1",
20-
"ink-scroll-view": "0.3.7",
21-
"marked": "^18.0.6",
22-
"marked-terminal": "^7.3.0",
23-
"react": "^19.2.7"
17+
"@opentui/core": "0.5.3",
18+
"@opentui/react": "0.5.3",
19+
"opentui-spinner": "^0.0.7",
20+
"react": "^19.2.7",
21+
"web-tree-sitter": "0.25.10"
2422
},
2523
"devDependencies": {
2624
"@biomejs/biome": "^2.5.4",
2725
"@types/bun": "^1.3.14",
28-
"@types/marked-terminal": "^6.1.1",
2926
"@types/react": "^19.2.17"
3027
},
3128
"peerDependencies": {

src/index.ts

Lines changed: 7 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,56 +1,26 @@
1-
import { render } from "ink";
1+
import { createCliRenderer } from "@opentui/core";
2+
import { createRoot } from "@opentui/react";
23
import { createElement } from "react";
34
import { resolveLlmClient } from "./adapters/llm";
45
import { createAgent } from "./agent";
56
import { createDemoAgent } from "./demo";
67
import systemPrompt from "./prompt.md";
78
import { App } from "./tui/App.tsx";
8-
import { initColorScheme } from "./tui/color-scheme.ts";
9-
10-
await initColorScheme();
119

1210
const agent =
1311
process.env.DEMO === "1"
1412
? await createDemoAgent()
1513
: await createAgent(resolveLlmClient(systemPrompt));
1614

17-
const { waitUntilExit } = renderFullscreen(createElement(App, { agent, attachApiKey }));
18-
await waitUntilExit();
15+
const renderer = await createCliRenderer();
16+
17+
const themeMode = (await renderer.waitForThemeMode(200)) ?? "dark";
18+
19+
createRoot(renderer).render(createElement(App, { agent, attachApiKey, themeMode }));
1920

2021
function attachApiKey(apiKey: string) {
2122
const client = resolveLlmClient(systemPrompt, { ...process.env, ANTHROPIC_API_KEY: apiKey });
2223
if (!client) return null;
2324
agent.setClient(client);
2425
return agent.modelInfo();
2526
}
26-
27-
/*
28-
* Render in the alternate screen with the first frame at the top.
29-
*
30-
* Ink's `alternateScreen` switches buffers (`\x1b[?1049h`) but doesn't clear or
31-
* home the cursor before the first paint, so the frame starts wherever the cursor
32-
* was previously. Ink writes that escape just before painting, so we intercept it
33-
* and splice in a clear+home.
34-
*/
35-
function renderFullscreen(node: ReturnType<typeof createElement>) {
36-
const options = { exitOnCtrlC: true, alternateScreen: true };
37-
if (!process.stdout.isTTY) return render(node, options);
38-
39-
const stdout = process.stdout;
40-
const originalWrite = stdout.write.bind(stdout);
41-
const write = originalWrite as (...args: unknown[]) => boolean;
42-
43-
stdout.write = (chunk: string | Uint8Array, ...args: unknown[]) => {
44-
const result = write(chunk, ...args);
45-
if (typeof chunk === "string" && chunk.includes("\x1b[?1049h")) {
46-
originalWrite("\x1b[2J\x1b[H"); // clear + home, before Ink's first paint
47-
}
48-
return result;
49-
};
50-
51-
try {
52-
return render(node, options);
53-
} finally {
54-
stdout.write = originalWrite;
55-
}
56-
}

0 commit comments

Comments
 (0)