@truss-harness/tui 0.1.0
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/LICENSE +21 -0
- package/README.md +34 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +759 -0
- package/dist/file-browser.d.ts +21 -0
- package/dist/file-browser.js +147 -0
- package/logo.png +0 -0
- package/package.json +53 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Truss contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Truss TUI
|
|
2
|
+
|
|
3
|
+
<p align="center"><img src="./logo.png" width="96" alt="Truss logo"></p>
|
|
4
|
+
|
|
5
|
+
The Truss TUI is a full-screen terminal workspace for local coding models. It includes a collapsible directory-first file tree, fuzzy file search, syntax-highlighted editor and Git diff preview, agent chat, shell output, tool approvals, local-model selection, and workspace commands.
|
|
6
|
+
|
|
7
|
+
## Install and run
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm install -g @truss-harness/tui
|
|
11
|
+
truss-tui
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
The TUI reads the same `.truss-harness/config.json` profiles as `@truss-harness/cli`, or lets you select a detected Ollama, LM Studio, llama.cpp, or custom compatible endpoint at startup.
|
|
15
|
+
|
|
16
|
+
Configured MCP stdio servers are connected when an Agent session starts, and their connection status appears in the Terminal pane. Plan mode loads only servers explicitly marked `readOnly`. Run `truss-cli config path` to locate the shared user and workspace configuration files.
|
|
17
|
+
|
|
18
|
+
## Controls
|
|
19
|
+
|
|
20
|
+
| Key | Action |
|
|
21
|
+
| --- | --- |
|
|
22
|
+
| `Tab` / `Shift+Tab` | Move forward or backward between panes. |
|
|
23
|
+
| `Ctrl+Left` / `Ctrl+Right` | Move to the adjacent pane without cycling forward through every pane. |
|
|
24
|
+
| `/` | Fuzzy-search workspace files from the Files pane. |
|
|
25
|
+
| `Enter` | Send the chat input or open the selected file. |
|
|
26
|
+
| `Left` / `Right` | Collapse or expand the selected directory. |
|
|
27
|
+
| `m` | Open local model settings outside the chat pane. |
|
|
28
|
+
| `d` | Toggle the selected file's Git diff. |
|
|
29
|
+
| Type directly | Enter a command while the terminal pane has focus. |
|
|
30
|
+
| `?` | Open workspace command help. |
|
|
31
|
+
| `Esc` | Stop generation. |
|
|
32
|
+
| `Ctrl+C` | Stop generation, or exit while idle. |
|
|
33
|
+
|
|
34
|
+
Type `/init`, `/update [note]`, `/status`, `/clear-memory`, or `/help` into the Agent pane. They run locally without a model call. Approvals for model-initiated tools still respect the selected permission policy.
|
package/dist/bin.d.ts
ADDED
package/dist/bin.js
ADDED
|
@@ -0,0 +1,759 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { execFile as execFileCallback } from "node:child_process";
|
|
5
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
6
|
+
import { join, relative } from "node:path";
|
|
7
|
+
import { cwd } from "node:process";
|
|
8
|
+
import { promisify } from "node:util";
|
|
9
|
+
import { Box, render, Text, useApp, useInput } from "ink";
|
|
10
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
11
|
+
import { detectLocalEndpoints, listLocalModels } from "@truss-harness/provider-openai-compatible";
|
|
12
|
+
import { brand } from "@truss-harness/branding";
|
|
13
|
+
import { resolveConfiguration } from "@truss-harness/cli/config";
|
|
14
|
+
import { createClientRuntime } from "@truss-harness/cli/runtime";
|
|
15
|
+
import { executeWorkspaceCommand, FileWorkspacePlanStore, workspaceCommandHelp } from "@truss-harness/runtime";
|
|
16
|
+
import { buildFileTree, fuzzyFiles, syntaxTokens, wrapSyntaxTokens } from "./file-browser.js";
|
|
17
|
+
const execFile = promisify(execFileCallback);
|
|
18
|
+
const ignoredDirectories = new Set([".git", ".next", ".truss-harness", "node_modules", "dist", "coverage"]);
|
|
19
|
+
const focusOrder = ["files", "editor", "chat", "terminal"];
|
|
20
|
+
function truncate(value, length) {
|
|
21
|
+
return value.length <= length ? value : `${value.slice(0, Math.max(0, length - 3))}...`;
|
|
22
|
+
}
|
|
23
|
+
function clamp(value, minimum, maximum) {
|
|
24
|
+
return Math.min(Math.max(value, minimum), Math.max(minimum, maximum));
|
|
25
|
+
}
|
|
26
|
+
function visibleLines(items, count, offset) {
|
|
27
|
+
const end = Math.max(0, items.length - clamp(offset, 0, Math.max(0, items.length - count)));
|
|
28
|
+
return items.slice(Math.max(0, end - count), end);
|
|
29
|
+
}
|
|
30
|
+
function estimateTokens(value) {
|
|
31
|
+
const trimmed = value.trim();
|
|
32
|
+
return trimmed ? Math.ceil(trimmed.length / 4) : 0;
|
|
33
|
+
}
|
|
34
|
+
function formatTokenCount(value) {
|
|
35
|
+
if (value < 1_000)
|
|
36
|
+
return String(Math.round(value));
|
|
37
|
+
return `${(value / 1_000).toFixed(value >= 10_000 ? 0 : 1).replace(/\.0$/, "")}k`;
|
|
38
|
+
}
|
|
39
|
+
function wrapText(value, width) {
|
|
40
|
+
const result = [];
|
|
41
|
+
for (const sourceLine of value.split(/\r?\n/)) {
|
|
42
|
+
let remaining = sourceLine || " ";
|
|
43
|
+
while (remaining.length > width) {
|
|
44
|
+
const breakAt = Math.max(1, remaining.lastIndexOf(" ", width));
|
|
45
|
+
result.push(remaining.slice(0, breakAt).trimEnd());
|
|
46
|
+
remaining = remaining.slice(breakAt).trimStart();
|
|
47
|
+
}
|
|
48
|
+
result.push(remaining);
|
|
49
|
+
}
|
|
50
|
+
return result;
|
|
51
|
+
}
|
|
52
|
+
function chatDisplayLines(messages, busy, width) {
|
|
53
|
+
const transcript = messages.flatMap((message) => {
|
|
54
|
+
const content = message.content || (busy && message.role === "assistant" ? "Thinking..." : "");
|
|
55
|
+
return [
|
|
56
|
+
{ role: message.role, text: message.role === "user" ? "YOU" : "AGENT", header: true },
|
|
57
|
+
...wrapText(content, width).map((text) => ({ role: message.role, text, header: false }))
|
|
58
|
+
];
|
|
59
|
+
});
|
|
60
|
+
return transcript;
|
|
61
|
+
}
|
|
62
|
+
function configuredContextWindow() {
|
|
63
|
+
const configured = Number.parseInt(process.env.TRUSS_HARNESS_CONTEXT_WINDOW ?? "8192", 10);
|
|
64
|
+
return Number.isFinite(configured) ? Math.max(512, Math.min(1_000_000, configured)) : 8_192;
|
|
65
|
+
}
|
|
66
|
+
async function collectFiles(root, current = root, result = []) {
|
|
67
|
+
if (result.length >= 2_000)
|
|
68
|
+
return result;
|
|
69
|
+
for (const entry of await readdir(current, { withFileTypes: true })) {
|
|
70
|
+
if (result.length >= 2_000)
|
|
71
|
+
break;
|
|
72
|
+
if (entry.isDirectory() && !ignoredDirectories.has(entry.name))
|
|
73
|
+
await collectFiles(root, join(current, entry.name), result);
|
|
74
|
+
if (entry.isFile())
|
|
75
|
+
result.push({ path: relative(root, join(current, entry.name)) });
|
|
76
|
+
}
|
|
77
|
+
return result.sort((left, right) => left.path.localeCompare(right.path));
|
|
78
|
+
}
|
|
79
|
+
async function runTrackedCommand(command, workspaceRoot, onOutput, onProcess) {
|
|
80
|
+
await new Promise((resolve, reject) => {
|
|
81
|
+
const child = spawn(command, { cwd: workspaceRoot, shell: true, windowsHide: true });
|
|
82
|
+
onProcess?.(child);
|
|
83
|
+
child.stdout.on("data", (data) => onOutput(data.toString()));
|
|
84
|
+
child.stderr.on("data", (data) => onOutput(data.toString()));
|
|
85
|
+
child.on("error", reject);
|
|
86
|
+
child.on("close", (code) => {
|
|
87
|
+
onProcess?.(undefined);
|
|
88
|
+
onOutput(`\n[process exited: ${code ?? "unknown"}]\n`);
|
|
89
|
+
resolve();
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
function detectedPreviewUrl(output) {
|
|
94
|
+
const match = output.match(/https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::\d+)?(?:\/[^\s]*)?/i);
|
|
95
|
+
return match?.[0].replace(/[),.;]+$/, "").replace("0.0.0.0", "127.0.0.1");
|
|
96
|
+
}
|
|
97
|
+
function normalizedPreviewUrl(value) {
|
|
98
|
+
const normalized = /^[a-z][a-z\d+.-]*:\/\//i.test(value.trim()) ? value.trim() : `http://${value.trim()}`;
|
|
99
|
+
const url = new URL(normalized);
|
|
100
|
+
if (url.protocol !== "http:" && url.protocol !== "https:")
|
|
101
|
+
throw new Error("Preview URLs must use HTTP or HTTPS.");
|
|
102
|
+
return url.toString();
|
|
103
|
+
}
|
|
104
|
+
function openExternalPreview(value) {
|
|
105
|
+
const url = normalizedPreviewUrl(value);
|
|
106
|
+
const [command, arguments_] = process.platform === "win32"
|
|
107
|
+
? ["rundll32.exe", ["url.dll,FileProtocolHandler", url]]
|
|
108
|
+
: process.platform === "darwin"
|
|
109
|
+
? ["open", [url]]
|
|
110
|
+
: ["xdg-open", [url]];
|
|
111
|
+
const child = spawn(command, arguments_, { detached: true, stdio: "ignore", windowsHide: true });
|
|
112
|
+
child.once("error", () => undefined);
|
|
113
|
+
child.unref();
|
|
114
|
+
}
|
|
115
|
+
async function stopProcessTree(child) {
|
|
116
|
+
if (process.platform === "win32" && child.pid) {
|
|
117
|
+
try {
|
|
118
|
+
await execFile("taskkill", ["/PID", String(child.pid), "/T", "/F"]);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
// Fall back to the Node process handle if taskkill is unavailable.
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
child.kill();
|
|
126
|
+
}
|
|
127
|
+
function Panel({ title, active, children }) {
|
|
128
|
+
return _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: active ? "cyan" : "gray", paddingX: 1, width: "100%", height: "100%", overflow: "hidden", children: [_jsx(Text, { color: active ? "cyan" : "gray", bold: true, children: title }), children] });
|
|
129
|
+
}
|
|
130
|
+
function App({ initialConfiguration }) {
|
|
131
|
+
const { exit } = useApp();
|
|
132
|
+
const [viewport, setViewport] = useState(() => ({ columns: process.stdout.columns || 120, rows: process.stdout.rows || 36 }));
|
|
133
|
+
const workspaceRoot = useMemo(() => cwd(), []);
|
|
134
|
+
const [configuration, setConfiguration] = useState(initialConfiguration);
|
|
135
|
+
const [client, setClient] = useState();
|
|
136
|
+
const [sessionId, setSessionId] = useState();
|
|
137
|
+
const [files, setFiles] = useState([]);
|
|
138
|
+
const [fileIndex, setFileIndex] = useState(0);
|
|
139
|
+
const [collapsedDirectories, setCollapsedDirectories] = useState(() => new Set());
|
|
140
|
+
const [fileSearchInput, setFileSearchInput] = useState("");
|
|
141
|
+
const [fileSearchIndex, setFileSearchIndex] = useState(0);
|
|
142
|
+
const [openFilePath, setOpenFilePath] = useState();
|
|
143
|
+
const [editor, setEditor] = useState("Select a file from the workspace tree.");
|
|
144
|
+
const [editorTitle, setEditorTitle] = useState("Preview");
|
|
145
|
+
const [isDiff, setIsDiff] = useState(false);
|
|
146
|
+
const [focus, setFocus] = useState("chat");
|
|
147
|
+
const [screen, setScreen] = useState(initialConfiguration ? "workspace" : "settings");
|
|
148
|
+
const [chatInput, setChatInput] = useState("");
|
|
149
|
+
const [chat, setChat] = useState([]);
|
|
150
|
+
const [chatScroll, setChatScroll] = useState(0);
|
|
151
|
+
const [streamMetrics, setStreamMetrics] = useState({ startedAt: 0, generatedTokens: 0 });
|
|
152
|
+
const [terminalLines, setTerminalLines] = useState(["Terminal ready. Shell commands run in the workspace root; slash commands run locally."]);
|
|
153
|
+
const [terminalScroll, setTerminalScroll] = useState(0);
|
|
154
|
+
const [previewUrl, setPreviewUrl] = useState();
|
|
155
|
+
const [editorScroll, setEditorScroll] = useState(0);
|
|
156
|
+
const [busy, setBusy] = useState(false);
|
|
157
|
+
const [runStatus, setRunStatus] = useState("ready");
|
|
158
|
+
const [activePlan, setActivePlan] = useState();
|
|
159
|
+
const [pendingTool, setPendingTool] = useState();
|
|
160
|
+
const [commandInput, setCommandInput] = useState("");
|
|
161
|
+
const [endpoints, setEndpoints] = useState([]);
|
|
162
|
+
const [serverIndex, setServerIndex] = useState(0);
|
|
163
|
+
const [models, setModels] = useState([]);
|
|
164
|
+
const [modelIndex, setModelIndex] = useState(0);
|
|
165
|
+
const [settingsField, setSettingsField] = useState("server");
|
|
166
|
+
const [endpointInput, setEndpointInput] = useState(initialConfiguration?.baseUrl ?? "http://127.0.0.1:11434");
|
|
167
|
+
const [modelInput, setModelInput] = useState(initialConfiguration?.model ?? "");
|
|
168
|
+
const [providerKind, setProviderKind] = useState(initialConfiguration?.provider ?? "ollama");
|
|
169
|
+
const [agentMode] = useState(initialConfiguration?.mode ?? "chat");
|
|
170
|
+
const [permissionMode] = useState(initialConfiguration?.permission ?? "ask");
|
|
171
|
+
const [internetAccess, setInternetAccess] = useState(initialConfiguration?.internetAccess ?? false);
|
|
172
|
+
const approvalResolvers = useRef(new Map());
|
|
173
|
+
const abortController = useRef(undefined);
|
|
174
|
+
const terminalProcess = useRef(undefined);
|
|
175
|
+
const contextWindow = useMemo(configuredContextWindow, []);
|
|
176
|
+
const contextTokens = useMemo(() => chat.reduce((total, message) => total + estimateTokens(message.content), 400), [chat]);
|
|
177
|
+
const tokensPerSecond = streamMetrics.startedAt && streamMetrics.generatedTokens
|
|
178
|
+
? streamMetrics.generatedTokens / Math.max((Date.now() - streamMetrics.startedAt) / 1_000, 0.1)
|
|
179
|
+
: undefined;
|
|
180
|
+
const appendTerminal = (output) => {
|
|
181
|
+
const next = output.replace(/\r/g, "").split("\n").filter(Boolean);
|
|
182
|
+
if (!next.length)
|
|
183
|
+
return;
|
|
184
|
+
const detected = detectedPreviewUrl(output);
|
|
185
|
+
if (detected)
|
|
186
|
+
setPreviewUrl(detected);
|
|
187
|
+
setTerminalScroll(0);
|
|
188
|
+
setTerminalLines((current) => [...current, ...next].slice(-110));
|
|
189
|
+
};
|
|
190
|
+
const candidates = useMemo(() => [
|
|
191
|
+
...endpoints,
|
|
192
|
+
{ id: "custom", label: "Custom compatible endpoint", kind: "openai-compatible", baseUrl: "" }
|
|
193
|
+
], [endpoints]);
|
|
194
|
+
const selectedEndpoint = candidates[Math.min(serverIndex, Math.max(candidates.length - 1, 0))];
|
|
195
|
+
const fileTree = useMemo(() => buildFileTree(files, collapsedDirectories), [files, collapsedDirectories]);
|
|
196
|
+
const selectedFileTreeEntry = fileTree[Math.min(fileIndex, Math.max(0, fileTree.length - 1))];
|
|
197
|
+
const fileSearchResults = useMemo(() => fuzzyFiles(files, fileSearchInput, Math.max(6, Math.min(14, viewport.rows - 10))), [files, fileSearchInput, viewport.rows]);
|
|
198
|
+
const compactLayout = viewport.columns < 106;
|
|
199
|
+
const terminalHeight = clamp(Math.floor(viewport.rows * 0.24), 7, 11);
|
|
200
|
+
const workspaceHeight = Math.max(compactLayout ? 14 : 9, viewport.rows - terminalHeight - 6);
|
|
201
|
+
const filesWidth = Math.max(20, Math.min(36, Math.floor(viewport.columns * 0.24)));
|
|
202
|
+
const chatWidth = compactLayout ? Math.max(40, viewport.columns - 4) : Math.max(28, Math.min(48, Math.floor(viewport.columns * 0.31)));
|
|
203
|
+
const editorWidth = compactLayout ? Math.max(30, viewport.columns - filesWidth - 4) : Math.max(30, viewport.columns - filesWidth - chatWidth - 4);
|
|
204
|
+
const compactChatHeight = compactLayout ? clamp(Math.floor(workspaceHeight * 0.4), 6, 10) : workspaceHeight;
|
|
205
|
+
const editorHeight = compactLayout ? Math.max(6, workspaceHeight - compactChatHeight - 1) : workspaceHeight;
|
|
206
|
+
const editorLineCount = Math.max(2, editorHeight - 3);
|
|
207
|
+
const chatLineCount = Math.max(2, compactChatHeight - (activePlan ? 7 : 0) - 4);
|
|
208
|
+
const chatTranscript = chatDisplayLines(chat, busy, Math.max(12, chatWidth - 5));
|
|
209
|
+
const chatLines = visibleLines(chatTranscript, chatLineCount, chatScroll);
|
|
210
|
+
const editorRows = useMemo(() => {
|
|
211
|
+
const maximumCharacters = Math.max(12, editorWidth - 7);
|
|
212
|
+
return editor.split(/\r?\n/).flatMap((line, sourceIndex) => {
|
|
213
|
+
const color = isDiff
|
|
214
|
+
? line.startsWith("+") && !line.startsWith("+++")
|
|
215
|
+
? "green"
|
|
216
|
+
: line.startsWith("-") && !line.startsWith("---")
|
|
217
|
+
? "red"
|
|
218
|
+
: undefined
|
|
219
|
+
: undefined;
|
|
220
|
+
const tokens = isDiff ? [{ text: line, color }] : syntaxTokens(line, openFilePath ?? "");
|
|
221
|
+
return wrapSyntaxTokens(tokens, maximumCharacters).map((row, rowIndex) => ({
|
|
222
|
+
sourceLine: sourceIndex + 1,
|
|
223
|
+
continuation: rowIndex > 0,
|
|
224
|
+
tokens: row
|
|
225
|
+
}));
|
|
226
|
+
});
|
|
227
|
+
}, [editor, editorWidth, isDiff, openFilePath]);
|
|
228
|
+
const visibleEditorRows = editorRows.slice(editorScroll, editorScroll + editorLineCount);
|
|
229
|
+
const visibleTerminalLines = visibleLines(terminalLines, Math.max(1, terminalHeight - 4), terminalScroll);
|
|
230
|
+
const fileTreeStart = clamp(fileIndex - Math.floor(editorLineCount / 2), 0, Math.max(0, fileTree.length - editorLineCount));
|
|
231
|
+
const visibleFileTree = fileTree.slice(fileTreeStart, fileTreeStart + editorLineCount);
|
|
232
|
+
const overlayWidth = clamp(viewport.columns - 4, 42, 90);
|
|
233
|
+
useEffect(() => {
|
|
234
|
+
void collectFiles(workspaceRoot).then(setFiles).catch((error) => setEditor(`Unable to list workspace files: ${String(error)}`));
|
|
235
|
+
void new FileWorkspacePlanStore(workspaceRoot).load().then(setActivePlan).catch(() => undefined);
|
|
236
|
+
void detectLocalEndpoints().then((detected) => {
|
|
237
|
+
setEndpoints(detected);
|
|
238
|
+
const preferred = configuration ? detected.findIndex((endpoint) => endpoint.kind === configuration.provider && endpoint.baseUrl === configuration.baseUrl) : -1;
|
|
239
|
+
if (preferred >= 0)
|
|
240
|
+
setServerIndex(preferred);
|
|
241
|
+
});
|
|
242
|
+
}, [workspaceRoot]);
|
|
243
|
+
useEffect(() => {
|
|
244
|
+
setFileIndex((current) => clamp(current, 0, Math.max(0, fileTree.length - 1)));
|
|
245
|
+
}, [fileTree.length]);
|
|
246
|
+
useEffect(() => {
|
|
247
|
+
setEditorScroll((current) => clamp(current, 0, Math.max(0, editorRows.length - editorLineCount)));
|
|
248
|
+
}, [editorRows.length, editorLineCount]);
|
|
249
|
+
useEffect(() => {
|
|
250
|
+
if (!openFilePath)
|
|
251
|
+
return;
|
|
252
|
+
const index = fileTree.findIndex((entry) => entry.kind === "file" && entry.path === openFilePath);
|
|
253
|
+
if (index >= 0)
|
|
254
|
+
setFileIndex(index);
|
|
255
|
+
}, [fileTree, openFilePath]);
|
|
256
|
+
useEffect(() => {
|
|
257
|
+
const refreshViewport = () => setViewport({ columns: process.stdout.columns || 120, rows: process.stdout.rows || 36 });
|
|
258
|
+
process.stdout.on("resize", refreshViewport);
|
|
259
|
+
return () => { process.stdout.off("resize", refreshViewport); };
|
|
260
|
+
}, []);
|
|
261
|
+
useEffect(() => {
|
|
262
|
+
if (!selectedEndpoint || !endpointInput)
|
|
263
|
+
return;
|
|
264
|
+
const endpoint = { ...selectedEndpoint, kind: providerKind, baseUrl: endpointInput };
|
|
265
|
+
void listLocalModels(endpoint).then((available) => {
|
|
266
|
+
setModels(available.map((model) => model.name));
|
|
267
|
+
const selected = available.findIndex((model) => model.name === modelInput);
|
|
268
|
+
setModelIndex(Math.max(0, selected));
|
|
269
|
+
}).catch(() => setModels([]));
|
|
270
|
+
}, [selectedEndpoint?.id, endpointInput, providerKind]);
|
|
271
|
+
useEffect(() => {
|
|
272
|
+
if (!client)
|
|
273
|
+
return;
|
|
274
|
+
const unsubscribe = client.events.subscribe((event) => {
|
|
275
|
+
if (event.type === "text_delta") {
|
|
276
|
+
setRunStatus("thinking");
|
|
277
|
+
setChatScroll(0);
|
|
278
|
+
setStreamMetrics((current) => current.startedAt
|
|
279
|
+
? { ...current, generatedTokens: current.generatedTokens + estimateTokens(event.text) }
|
|
280
|
+
: current);
|
|
281
|
+
setChat((current) => {
|
|
282
|
+
const last = current.at(-1);
|
|
283
|
+
if (!last || last.role !== "assistant")
|
|
284
|
+
return [...current, { role: "assistant", content: event.text }];
|
|
285
|
+
return [...current.slice(0, -1), { role: "assistant", content: last.content + event.text }];
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
if (event.type === "tool_call_requested") {
|
|
289
|
+
setRunStatus("tool");
|
|
290
|
+
appendTerminal(`[tool requested] ${event.tool} ${JSON.stringify(event.input)}`);
|
|
291
|
+
}
|
|
292
|
+
if (event.type === "tool_completed") {
|
|
293
|
+
setRunStatus("thinking");
|
|
294
|
+
appendTerminal(`[tool completed] ${event.tool}: ${truncate(event.result.content, 160)}`);
|
|
295
|
+
}
|
|
296
|
+
if (event.type === "plan_updated")
|
|
297
|
+
setActivePlan(event.plan);
|
|
298
|
+
if (event.type === "run_failed") {
|
|
299
|
+
setRunStatus("ready");
|
|
300
|
+
appendTerminal(`[agent error] ${event.error.message}`);
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
return () => {
|
|
304
|
+
unsubscribe();
|
|
305
|
+
void client.dispose();
|
|
306
|
+
};
|
|
307
|
+
}, [client]);
|
|
308
|
+
const loadFile = async (entry) => {
|
|
309
|
+
try {
|
|
310
|
+
setIsDiff(false);
|
|
311
|
+
setEditorScroll(0);
|
|
312
|
+
setOpenFilePath(entry.path);
|
|
313
|
+
setEditorTitle(entry.path);
|
|
314
|
+
setEditor(await readFile(join(workspaceRoot, entry.path), "utf8"));
|
|
315
|
+
setFocus("editor");
|
|
316
|
+
}
|
|
317
|
+
catch (error) {
|
|
318
|
+
setEditor(`Unable to read ${entry.path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
const toggleDirectory = (path, expand) => {
|
|
322
|
+
setCollapsedDirectories((current) => {
|
|
323
|
+
const next = new Set(current);
|
|
324
|
+
const currentlyExpanded = !next.has(path);
|
|
325
|
+
const shouldExpand = expand ?? !currentlyExpanded;
|
|
326
|
+
if (shouldExpand)
|
|
327
|
+
next.delete(path);
|
|
328
|
+
else
|
|
329
|
+
next.add(path);
|
|
330
|
+
return next;
|
|
331
|
+
});
|
|
332
|
+
};
|
|
333
|
+
const openSearchResult = (entry) => {
|
|
334
|
+
const parts = entry.path.replaceAll("\\", "/").split("/");
|
|
335
|
+
parts.pop();
|
|
336
|
+
setCollapsedDirectories((current) => {
|
|
337
|
+
const next = new Set(current);
|
|
338
|
+
let path = "";
|
|
339
|
+
for (const part of parts) {
|
|
340
|
+
path = path ? `${path}/${part}` : part;
|
|
341
|
+
next.delete(path);
|
|
342
|
+
}
|
|
343
|
+
return next;
|
|
344
|
+
});
|
|
345
|
+
setFileSearchInput("");
|
|
346
|
+
setFileSearchIndex(0);
|
|
347
|
+
setScreen("workspace");
|
|
348
|
+
void loadFile(entry);
|
|
349
|
+
};
|
|
350
|
+
const toggleDiff = async () => {
|
|
351
|
+
if (!openFilePath)
|
|
352
|
+
return;
|
|
353
|
+
const entry = { path: openFilePath };
|
|
354
|
+
if (isDiff)
|
|
355
|
+
return void loadFile(entry);
|
|
356
|
+
try {
|
|
357
|
+
setOpenFilePath(entry.path);
|
|
358
|
+
const { stdout } = await execFile("git", ["diff", "--no-ext-diff", "--", entry.path], { cwd: workspaceRoot, maxBuffer: 1_000_000 });
|
|
359
|
+
setEditorTitle(`Diff: ${entry.path}`);
|
|
360
|
+
setEditor(stdout || "No working-tree diff for this file.");
|
|
361
|
+
setIsDiff(true);
|
|
362
|
+
setEditorScroll(0);
|
|
363
|
+
}
|
|
364
|
+
catch (error) {
|
|
365
|
+
setEditor(`Unable to load Git diff: ${error instanceof Error ? error.message : String(error)}`);
|
|
366
|
+
setIsDiff(true);
|
|
367
|
+
}
|
|
368
|
+
};
|
|
369
|
+
const configureRuntime = async () => {
|
|
370
|
+
if (!endpointInput.trim() || !modelInput.trim()) {
|
|
371
|
+
appendTerminal("[configuration] An endpoint and model are required.");
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
const nextConfiguration = {
|
|
375
|
+
workspaceRoot,
|
|
376
|
+
provider: providerKind,
|
|
377
|
+
baseUrl: endpointInput.trim(),
|
|
378
|
+
model: modelInput.trim(),
|
|
379
|
+
apiKey: process.env.TRUSS_HARNESS_API_KEY,
|
|
380
|
+
systemPrompt: process.env.TRUSS_HARNESS_SYSTEM_PROMPT,
|
|
381
|
+
mode: agentMode,
|
|
382
|
+
internetAccess,
|
|
383
|
+
approval: {
|
|
384
|
+
approve: async (call) => {
|
|
385
|
+
const readOnly = ["read_file", "list_directory", "search_files", "grep"].includes(call.name);
|
|
386
|
+
if (permissionMode === "auto-all" || (permissionMode === "auto-read" && readOnly))
|
|
387
|
+
return true;
|
|
388
|
+
return new Promise((resolve) => {
|
|
389
|
+
approvalResolvers.current.set(call.id, resolve);
|
|
390
|
+
setPendingTool(call);
|
|
391
|
+
setRunStatus("waiting");
|
|
392
|
+
setScreen("approval");
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
};
|
|
397
|
+
setConfiguration(nextConfiguration);
|
|
398
|
+
setRunStatus("thinking");
|
|
399
|
+
try {
|
|
400
|
+
const nextClient = await createClientRuntime(nextConfiguration);
|
|
401
|
+
setClient(nextClient);
|
|
402
|
+
for (const server of nextClient.mcpServers) {
|
|
403
|
+
appendTerminal(`[mcp] ${server.name}: ${server.state}${server.error ? ` (${server.error})` : ` (${server.toolCount} tools)`}`);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
catch (error) {
|
|
407
|
+
appendTerminal(`[mcp] Unable to configure runtime: ${error instanceof Error ? error.message : String(error)}`);
|
|
408
|
+
setRunStatus("ready");
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
setSessionId(undefined);
|
|
412
|
+
setChat([]);
|
|
413
|
+
setChatScroll(0);
|
|
414
|
+
setStreamMetrics({ startedAt: 0, generatedTokens: 0 });
|
|
415
|
+
setScreen("workspace");
|
|
416
|
+
appendTerminal(`[configuration] ${nextConfiguration.provider} ${nextConfiguration.model} at ${nextConfiguration.baseUrl}`);
|
|
417
|
+
setRunStatus("ready");
|
|
418
|
+
};
|
|
419
|
+
useEffect(() => {
|
|
420
|
+
if (initialConfiguration && !client)
|
|
421
|
+
void configureRuntime();
|
|
422
|
+
}, []);
|
|
423
|
+
const openFileContext = async () => {
|
|
424
|
+
if (!openFilePath)
|
|
425
|
+
return [];
|
|
426
|
+
try {
|
|
427
|
+
// Keep the active file useful on small local context windows without crowding out the request and history.
|
|
428
|
+
const maximumCharacters = Math.max(2_000, Math.min(20_000, contextWindow));
|
|
429
|
+
const content = await readFile(join(workspaceRoot, openFilePath), "utf8");
|
|
430
|
+
const clipped = content.slice(0, maximumCharacters);
|
|
431
|
+
return [{
|
|
432
|
+
source: `active-file:${openFilePath}`,
|
|
433
|
+
content: `This is the currently open workspace file and the primary context for this request. Tool results produced later in the run take precedence over this request-start snapshot.\n\n${clipped}`,
|
|
434
|
+
priority: 1_000
|
|
435
|
+
}];
|
|
436
|
+
}
|
|
437
|
+
catch {
|
|
438
|
+
return [];
|
|
439
|
+
}
|
|
440
|
+
};
|
|
441
|
+
const sendPrompt = async () => {
|
|
442
|
+
const prompt = chatInput.trim();
|
|
443
|
+
if (!prompt || busy)
|
|
444
|
+
return;
|
|
445
|
+
const command = await executeWorkspaceCommand({ workspaceRoot, input: prompt });
|
|
446
|
+
if (command.handled) {
|
|
447
|
+
setChatInput("");
|
|
448
|
+
setChat((current) => [...current, { role: "user", content: prompt }, { role: "assistant", content: command.message }]);
|
|
449
|
+
appendTerminal(`[workspace command] ${command.command ?? prompt}: ${command.ok ? "completed" : "failed"}`);
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
if (!client) {
|
|
453
|
+
setScreen("settings");
|
|
454
|
+
appendTerminal("[configuration] Select a local model before sending a prompt.");
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
setChatInput("");
|
|
458
|
+
setChatScroll(0);
|
|
459
|
+
setBusy(true);
|
|
460
|
+
setRunStatus("thinking");
|
|
461
|
+
setStreamMetrics({ startedAt: Date.now(), generatedTokens: 0 });
|
|
462
|
+
setChat((current) => [...current, { role: "user", content: prompt }, { role: "assistant", content: "" }]);
|
|
463
|
+
const controller = new AbortController();
|
|
464
|
+
abortController.current = controller;
|
|
465
|
+
try {
|
|
466
|
+
const session = sessionId ? await client.runtime.getSession(sessionId) : await client.runtime.createSession();
|
|
467
|
+
if (!session)
|
|
468
|
+
throw new Error("Conversation is unavailable.");
|
|
469
|
+
setSessionId(session.id);
|
|
470
|
+
await client.runtime.run(session.id, prompt, controller.signal, await openFileContext());
|
|
471
|
+
}
|
|
472
|
+
catch (error) {
|
|
473
|
+
if (!controller.signal.aborted)
|
|
474
|
+
appendTerminal(`[agent error] ${error instanceof Error ? error.message : String(error)}`);
|
|
475
|
+
}
|
|
476
|
+
finally {
|
|
477
|
+
setBusy(false);
|
|
478
|
+
setRunStatus("ready");
|
|
479
|
+
abortController.current = undefined;
|
|
480
|
+
}
|
|
481
|
+
};
|
|
482
|
+
const resolveApproval = (approved) => {
|
|
483
|
+
if (pendingTool)
|
|
484
|
+
approvalResolvers.current.get(pendingTool.id)?.(approved);
|
|
485
|
+
setPendingTool(undefined);
|
|
486
|
+
setRunStatus(approved ? "thinking" : "ready");
|
|
487
|
+
setScreen("workspace");
|
|
488
|
+
};
|
|
489
|
+
const startNewConversation = () => {
|
|
490
|
+
if (busy) {
|
|
491
|
+
appendTerminal("[conversation] Stop the active run before starting a new conversation.");
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
setSessionId(undefined);
|
|
495
|
+
setChat([]);
|
|
496
|
+
setChatInput("");
|
|
497
|
+
setChatScroll(0);
|
|
498
|
+
setStreamMetrics({ startedAt: 0, generatedTokens: 0 });
|
|
499
|
+
appendTerminal("[conversation] Started a new conversation.");
|
|
500
|
+
};
|
|
501
|
+
const runTerminalInput = async (input) => {
|
|
502
|
+
const command = await executeWorkspaceCommand({ workspaceRoot, input });
|
|
503
|
+
if (command.handled) {
|
|
504
|
+
appendTerminal(`[workspace command] ${command.command ?? input}: ${command.ok ? "completed" : "failed"}`);
|
|
505
|
+
appendTerminal(command.message);
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
await runTrackedCommand(input, workspaceRoot, appendTerminal, (process) => { terminalProcess.current = process; });
|
|
509
|
+
};
|
|
510
|
+
const moveFocus = (direction) => {
|
|
511
|
+
setFocus((current) => {
|
|
512
|
+
const currentIndex = focusOrder.indexOf(current);
|
|
513
|
+
return focusOrder[(currentIndex + direction + focusOrder.length) % focusOrder.length];
|
|
514
|
+
});
|
|
515
|
+
};
|
|
516
|
+
useInput((input, key) => {
|
|
517
|
+
if (key.ctrl && input === "c") {
|
|
518
|
+
if (terminalProcess.current) {
|
|
519
|
+
const process = terminalProcess.current;
|
|
520
|
+
terminalProcess.current = undefined;
|
|
521
|
+
void stopProcessTree(process).then(() => appendTerminal("[terminal] Process stopped."));
|
|
522
|
+
}
|
|
523
|
+
else if (busy) {
|
|
524
|
+
abortController.current?.abort();
|
|
525
|
+
setRunStatus("ready");
|
|
526
|
+
appendTerminal("[agent] Cancellation requested.");
|
|
527
|
+
}
|
|
528
|
+
else
|
|
529
|
+
exit();
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
if (screen === "approval") {
|
|
533
|
+
if (input.toLowerCase() === "y" || key.return)
|
|
534
|
+
resolveApproval(true);
|
|
535
|
+
if (input.toLowerCase() === "n" || key.escape)
|
|
536
|
+
resolveApproval(false);
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
if (screen === "file-search") {
|
|
540
|
+
if (key.escape) {
|
|
541
|
+
setFileSearchInput("");
|
|
542
|
+
setFileSearchIndex(0);
|
|
543
|
+
setScreen("workspace");
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
if (key.upArrow) {
|
|
547
|
+
setFileSearchIndex((current) => Math.max(0, current - 1));
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
if (key.downArrow) {
|
|
551
|
+
setFileSearchIndex((current) => Math.min(Math.max(0, fileSearchResults.length - 1), current + 1));
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
if (key.return) {
|
|
555
|
+
const result = fileSearchResults[Math.min(fileSearchIndex, Math.max(0, fileSearchResults.length - 1))];
|
|
556
|
+
if (result)
|
|
557
|
+
openSearchResult(result);
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
if (key.backspace || key.delete) {
|
|
561
|
+
setFileSearchInput((current) => current.slice(0, -1));
|
|
562
|
+
setFileSearchIndex(0);
|
|
563
|
+
}
|
|
564
|
+
else if (input) {
|
|
565
|
+
setFileSearchInput((current) => current + input);
|
|
566
|
+
setFileSearchIndex(0);
|
|
567
|
+
}
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
if (screen === "help") {
|
|
571
|
+
setScreen("workspace");
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
if (screen === "settings") {
|
|
575
|
+
if (key.escape) {
|
|
576
|
+
setScreen("workspace");
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
if (key.tab) {
|
|
580
|
+
const fields = ["server", "endpoint", "model", "internet"];
|
|
581
|
+
setSettingsField((current) => {
|
|
582
|
+
const index = fields.indexOf(current);
|
|
583
|
+
return fields[(index + (key.shift ? -1 : 1) + fields.length) % fields.length];
|
|
584
|
+
});
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
if (settingsField === "server") {
|
|
588
|
+
if (key.upArrow)
|
|
589
|
+
setServerIndex((current) => Math.max(0, current - 1));
|
|
590
|
+
if (key.downArrow)
|
|
591
|
+
setServerIndex((current) => Math.min(candidates.length - 1, current + 1));
|
|
592
|
+
if (key.return && selectedEndpoint) {
|
|
593
|
+
setProviderKind(selectedEndpoint.kind);
|
|
594
|
+
setEndpointInput(selectedEndpoint.baseUrl);
|
|
595
|
+
setSettingsField("model");
|
|
596
|
+
}
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
if (settingsField === "model") {
|
|
600
|
+
if (key.upArrow)
|
|
601
|
+
setModelIndex((current) => Math.max(0, current - 1));
|
|
602
|
+
if (key.downArrow)
|
|
603
|
+
setModelIndex((current) => Math.min(models.length - 1, current + 1));
|
|
604
|
+
if (models[modelIndex] && (key.return || key.rightArrow))
|
|
605
|
+
setModelInput(models[modelIndex]);
|
|
606
|
+
}
|
|
607
|
+
if (settingsField === "internet") {
|
|
608
|
+
if (input === " " || key.leftArrow || key.rightArrow)
|
|
609
|
+
setInternetAccess((current) => !current);
|
|
610
|
+
if (key.return && endpointInput && modelInput)
|
|
611
|
+
void configureRuntime();
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
if (key.return && endpointInput && modelInput) {
|
|
615
|
+
void configureRuntime();
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
const setter = settingsField === "endpoint" ? setEndpointInput : setModelInput;
|
|
619
|
+
if (key.backspace || key.delete)
|
|
620
|
+
setter((current) => current.slice(0, -1));
|
|
621
|
+
else if (!key.return && input)
|
|
622
|
+
setter((current) => current + input);
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
if (key.ctrl && (key.leftArrow || key.rightArrow)) {
|
|
626
|
+
moveFocus(key.leftArrow ? -1 : 1);
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
if (key.tab) {
|
|
630
|
+
moveFocus(key.shift ? -1 : 1);
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
if (key.escape && busy) {
|
|
634
|
+
abortController.current?.abort();
|
|
635
|
+
setRunStatus("ready");
|
|
636
|
+
appendTerminal("[agent] Cancellation requested.");
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
if (input === "o" && focus !== "chat" && focus !== "terminal" && previewUrl) {
|
|
640
|
+
try {
|
|
641
|
+
openExternalPreview(previewUrl);
|
|
642
|
+
appendTerminal(`[preview] Opened ${previewUrl}`);
|
|
643
|
+
}
|
|
644
|
+
catch (error) {
|
|
645
|
+
appendTerminal(`[preview error] ${error instanceof Error ? error.message : String(error)}`);
|
|
646
|
+
}
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
if (focus === "terminal") {
|
|
650
|
+
if (key.upArrow) {
|
|
651
|
+
setTerminalScroll((current) => clamp(current + 1, 0, Math.max(0, terminalLines.length - Math.max(1, terminalHeight - 4))));
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
if (key.downArrow) {
|
|
655
|
+
setTerminalScroll((current) => clamp(current - 1, 0, Math.max(0, terminalLines.length - Math.max(1, terminalHeight - 4))));
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
if (key.return) {
|
|
659
|
+
const command = commandInput.trim();
|
|
660
|
+
setCommandInput("");
|
|
661
|
+
if (command)
|
|
662
|
+
void runTerminalInput(command).catch((error) => appendTerminal(`[terminal error] ${String(error)}`));
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
if (key.backspace || key.delete)
|
|
666
|
+
setCommandInput((current) => current.slice(0, -1));
|
|
667
|
+
else if (input)
|
|
668
|
+
setCommandInput((current) => current + input);
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
if (input === "?" && focus !== "chat") {
|
|
672
|
+
setScreen("help");
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
if (input === "m" && focus !== "chat") {
|
|
676
|
+
setScreen("settings");
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
if (input === "n" && focus !== "chat") {
|
|
680
|
+
startNewConversation();
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
if (focus === "files") {
|
|
684
|
+
if (input === "/") {
|
|
685
|
+
setFileSearchInput("");
|
|
686
|
+
setFileSearchIndex(0);
|
|
687
|
+
setScreen("file-search");
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
if (key.upArrow)
|
|
691
|
+
setFileIndex((current) => Math.max(0, current - 1));
|
|
692
|
+
if (key.downArrow)
|
|
693
|
+
setFileIndex((current) => Math.min(Math.max(fileTree.length - 1, 0), current + 1));
|
|
694
|
+
if (key.leftArrow && selectedFileTreeEntry?.kind === "directory")
|
|
695
|
+
toggleDirectory(selectedFileTreeEntry.path, false);
|
|
696
|
+
if (key.rightArrow && selectedFileTreeEntry?.kind === "directory")
|
|
697
|
+
toggleDirectory(selectedFileTreeEntry.path, true);
|
|
698
|
+
if (key.return && selectedFileTreeEntry?.kind === "directory")
|
|
699
|
+
toggleDirectory(selectedFileTreeEntry.path);
|
|
700
|
+
if (key.return && selectedFileTreeEntry?.kind === "file")
|
|
701
|
+
void loadFile(selectedFileTreeEntry);
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
if (focus === "editor") {
|
|
705
|
+
if (key.upArrow) {
|
|
706
|
+
setEditorScroll((current) => clamp(current - 1, 0, Math.max(0, editorRows.length - editorLineCount)));
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
if (key.downArrow) {
|
|
710
|
+
setEditorScroll((current) => clamp(current + 1, 0, Math.max(0, editorRows.length - editorLineCount)));
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
if (input === "d")
|
|
714
|
+
void toggleDiff();
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
if (key.upArrow) {
|
|
718
|
+
setChatScroll((current) => clamp(current + 1, 0, Math.max(0, chatTranscript.length - chatLineCount)));
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
if (key.downArrow) {
|
|
722
|
+
setChatScroll((current) => clamp(current - 1, 0, Math.max(0, chatTranscript.length - chatLineCount)));
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
if (key.return) {
|
|
726
|
+
void sendPrompt();
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
if (key.backspace || key.delete)
|
|
730
|
+
setChatInput((current) => current.slice(0, -1));
|
|
731
|
+
else if (input)
|
|
732
|
+
setChatInput((current) => current + input);
|
|
733
|
+
});
|
|
734
|
+
const filesPanel = _jsxs(Panel, { title: "FILES [/] find", active: focus === "files", children: [visibleFileTree.map((entry, visibleIndex) => {
|
|
735
|
+
const selected = fileTreeStart + visibleIndex === fileIndex;
|
|
736
|
+
const marker = entry.kind === "directory" ? (entry.expanded ? "v " : "> ") : " ";
|
|
737
|
+
const indentation = " ".repeat(entry.depth * 2);
|
|
738
|
+
const width = Math.max(6, filesWidth - indentation.length - 7);
|
|
739
|
+
return _jsxs(Text, { wrap: "truncate-end", color: selected ? "cyan" : entry.kind === "directory" ? "blue" : entry.path === openFilePath ? "green" : undefined, bold: selected || entry.kind === "directory", children: [selected ? "> " : " ", indentation, marker, truncate(entry.name, width)] }, `${entry.kind}:${entry.path}`);
|
|
740
|
+
}), !fileTree.length && _jsx(Text, { color: "gray", children: "No workspace files found." })] });
|
|
741
|
+
const editorPanel = _jsx(Panel, { title: `${editorTitle} [up/down] scroll [d] diff${previewUrl ? " [o] browser" : ""}`, active: focus === "editor", children: visibleEditorRows.map((row, index) => {
|
|
742
|
+
const lineLabel = row.continuation ? " |" : String(row.sourceLine).padStart(3);
|
|
743
|
+
return _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: "gray", children: lineLabel }), " ", row.tokens.map((token, tokenIndex) => _jsx(Text, { color: token.color, dimColor: token.dim, children: token.text }, `${tokenIndex}-${token.text}`))] }, `${editorScroll + index}-${row.sourceLine}-${row.continuation}`);
|
|
744
|
+
}) });
|
|
745
|
+
const agentPanel = _jsxs(Panel, { title: chatScroll ? `AGENT [${chatScroll} lines above]` : "AGENT [Enter] send", active: focus === "chat", children: [activePlan && _jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Text, { bold: true, color: "cyan", wrap: "truncate-end", children: ["PLAN: ", truncate(activePlan.title, Math.max(8, chatWidth - 9))] }), activePlan.steps.slice(0, 3).map((step) => _jsxs(Text, { wrap: "truncate-end", color: step.status === "completed" ? "green" : step.status === "in_progress" ? "yellow" : "gray", children: [step.status === "completed" ? "[x]" : step.status === "in_progress" ? "[..]" : "[ ]", " ", truncate(step.content, Math.max(8, chatWidth - 7))] }, step.id))] }), chatLines.map((line, index) => _jsx(Text, { color: line.role === "user" ? "cyan" : "green", bold: line.header, children: line.text }, `${line.role}-${line.header}-${index}-${line.text}`)), _jsx(Box, { flexGrow: 1 }), _jsxs(Text, { wrap: "truncate-end", color: focus === "chat" ? "cyan" : "gray", children: [focus === "chat" ? "> " : " ", truncate(chatInput || (busy ? "Working... Escape cancels" : "Ask about this workspace"), Math.max(12, chatWidth - 5))] })] });
|
|
746
|
+
return _jsxs(Box, { flexDirection: "column", height: viewport.rows, paddingX: 1, overflow: "hidden", children: [_jsxs(Box, { height: 1, justifyContent: "space-between", marginBottom: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: brand.productName.toUpperCase() }), _jsx(Text, { color: "gray", children: configuration ? `${configuration.provider} / ${configuration.model}` : "No model selected" }), _jsx(Text, { color: runStatus === "waiting" ? "yellow" : busy ? "cyan" : "green", children: runStatus === "waiting" ? "APPROVAL" : runStatus === "tool" ? "TOOL" : busy ? "WORKING" : "READY" })] }), _jsxs(Box, { height: 1, justifyContent: "space-between", marginBottom: 1, children: [_jsxs(Text, { color: "gray", children: ["CONTEXT ", _jsxs(Text, { color: contextTokens / contextWindow >= 0.9 ? "red" : contextTokens / contextWindow >= 0.7 ? "yellow" : "cyan", children: [formatTokenCount(contextTokens), " / ", formatTokenCount(contextWindow)] }), " estimated"] }), _jsxs(Text, { color: "gray", children: ["SPEED ", _jsx(Text, { color: busy ? "green" : "gray", children: tokensPerSecond ? `${tokensPerSecond.toFixed(1)} tok/s` : "-- tok/s" }), previewUrl ? ` PREVIEW ${truncate(previewUrl, Math.max(16, Math.floor(viewport.columns * 0.3)))}` : ""] })] }), compactLayout ? _jsxs(Box, { height: workspaceHeight, flexDirection: "column", gap: 1, overflow: "hidden", children: [_jsxs(Box, { height: editorHeight, flexDirection: "row", gap: 1, overflow: "hidden", children: [_jsx(Box, { width: filesWidth, height: "100%", children: filesPanel }), _jsx(Box, { width: editorWidth, height: "100%", children: editorPanel })] }), _jsx(Box, { height: compactChatHeight, width: "100%", children: agentPanel })] }) : _jsxs(Box, { height: workspaceHeight, flexDirection: "row", gap: 1, overflow: "hidden", children: [_jsx(Box, { width: filesWidth, height: "100%", children: filesPanel }), _jsx(Box, { width: editorWidth, height: "100%", children: editorPanel }), _jsx(Box, { width: chatWidth, height: "100%", children: agentPanel })] }), _jsx(Box, { height: terminalHeight, marginTop: 1, children: _jsxs(Panel, { title: "TERMINAL [Enter] run", active: focus === "terminal", children: [visibleTerminalLines.map((line, index) => _jsx(Text, { wrap: "truncate-end", color: line.startsWith("[agent error]") || line.startsWith("[terminal error]") ? "red" : line.startsWith("[tool") ? "yellow" : "gray", children: truncate(line, Math.max(20, viewport.columns - 8)) }, `${index}-${line}`)), _jsx(Box, { flexGrow: 1 }), _jsxs(Text, { wrap: "truncate-end", color: focus === "terminal" ? "cyan" : "gray", children: [focus === "terminal" ? "> " : " ", truncate(commandInput || "Type a workspace command", Math.max(16, viewport.columns - 10))] })] }) }), _jsxs(Box, { height: 1, marginTop: 1, justifyContent: "space-between", children: [_jsx(Text, { color: "gray", wrap: "truncate-end", children: "Tab/Shift+Tab focus | Ctrl+Left/Right pane | / find | m model | Esc stop" }), _jsx(Text, { color: "gray", children: sessionId ? `session ${sessionId.slice(0, 8)}` : "new session" })] }), screen === "file-search" && _jsxs(Box, { position: "absolute", flexDirection: "column", borderStyle: "double", borderColor: "cyan", paddingX: 2, paddingY: 1, width: overlayWidth, marginLeft: 2, marginTop: 3, backgroundColor: "black", children: [_jsx(Text, { bold: true, color: "cyan", children: "FIND FILE" }), _jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: "> " }), fileSearchInput || _jsx(Text, { color: "gray", children: "Type part of a file name or path" })] }), _jsx(Text, { color: "gray", children: "Up/Down select Enter open Escape close" }), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [fileSearchResults.map((entry, index) => {
|
|
747
|
+
const selected = index === Math.min(fileSearchIndex, Math.max(0, fileSearchResults.length - 1));
|
|
748
|
+
const fileName = entry.path.split("/").at(-1) ?? entry.path;
|
|
749
|
+
return _jsxs(Text, { color: selected ? "cyan" : undefined, bold: selected, children: [selected ? "> " : " ", fileName, _jsxs(Text, { color: "gray", children: [" ", truncate(entry.path, Math.max(12, overlayWidth - fileName.length - 10))] })] }, entry.path);
|
|
750
|
+
}), !fileSearchResults.length && _jsx(Text, { color: "yellow", children: "No matching files." })] })] }), screen === "settings" && _jsxs(Box, { position: "absolute", flexDirection: "column", borderStyle: "double", borderColor: "cyan", paddingX: 2, paddingY: 1, width: overlayWidth, marginLeft: 2, marginTop: 3, backgroundColor: "black", children: [_jsx(Text, { bold: true, color: "cyan", children: "LOCAL MODEL CONFIGURATION" }), _jsx(Text, { color: "gray", children: "Tab changes fields. Enter selects an item or saves when endpoint and model are set." }), _jsxs(Text, { color: settingsField === "server" ? "cyan" : undefined, children: ["SERVER: ", selectedEndpoint?.label ?? "Scanning local servers..."] }), _jsxs(Text, { color: settingsField === "endpoint" ? "cyan" : undefined, children: ["ENDPOINT: ", endpointInput] }), _jsxs(Text, { color: settingsField === "model" ? "cyan" : undefined, children: ["MODEL: ", modelInput || models[modelIndex] || "Choose or type a model"] }), _jsxs(Text, { color: settingsField === "internet" ? "cyan" : undefined, children: ["INTERNET RESEARCH: ", internetAccess ? "enabled" : "disabled", " ", settingsField === "internet" ? "[Space toggles]" : ""] }), _jsxs(Text, { color: "gray", children: ["Detected models: ", models.slice(0, 5).join(", ") || "none; type one manually"] }), _jsxs(Text, { color: "yellow", children: ["Provider: ", providerKind, ". Use the server selector for Ollama or compatible endpoints."] })] }), screen === "approval" && _jsxs(Box, { position: "absolute", flexDirection: "column", borderStyle: "double", borderColor: "yellow", paddingX: 2, paddingY: 1, width: overlayWidth, marginLeft: 2, marginTop: 8, backgroundColor: "black", children: [_jsx(Text, { bold: true, color: "yellow", children: "TOOL APPROVAL REQUIRED" }), _jsxs(Text, { children: [pendingTool?.name, " ", JSON.stringify(pendingTool?.input)] }), _jsx(Text, { color: "gray", children: "Press Y or Enter to allow. Press N or Escape to deny." })] }), screen === "help" && _jsxs(Box, { position: "absolute", flexDirection: "column", borderStyle: "double", borderColor: "cyan", paddingX: 2, paddingY: 1, width: overlayWidth, marginLeft: 2, marginTop: 7, backgroundColor: "black", children: [_jsx(Text, { bold: true, color: "cyan", children: "WORKSPACE COMMANDS" }), workspaceCommandHelp().split("\n").slice(1).map((line) => _jsx(Text, { children: line }, line)), _jsx(Text, { color: "gray", children: "Type slash commands in the Agent or Terminal pane. Press any key to close." })] })] });
|
|
751
|
+
}
|
|
752
|
+
const initialConfiguration = await resolveConfiguration({ workspaceRoot: cwd() }).catch((error) => {
|
|
753
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
754
|
+
if (message.startsWith("Set a model"))
|
|
755
|
+
return undefined;
|
|
756
|
+
throw error;
|
|
757
|
+
});
|
|
758
|
+
const application = render(_jsx(App, { initialConfiguration: initialConfiguration }));
|
|
759
|
+
await application.waitUntilExit();
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface FileEntry {
|
|
2
|
+
readonly path: string;
|
|
3
|
+
}
|
|
4
|
+
export interface FileTreeEntry {
|
|
5
|
+
readonly kind: "directory" | "file";
|
|
6
|
+
readonly path: string;
|
|
7
|
+
readonly name: string;
|
|
8
|
+
readonly depth: number;
|
|
9
|
+
readonly expanded?: boolean;
|
|
10
|
+
}
|
|
11
|
+
export interface SyntaxToken {
|
|
12
|
+
readonly text: string;
|
|
13
|
+
readonly color?: "blue" | "cyan" | "gray" | "green" | "magenta" | "red" | "yellow";
|
|
14
|
+
readonly dim?: boolean;
|
|
15
|
+
}
|
|
16
|
+
export declare function buildFileTree(files: readonly FileEntry[], collapsed: ReadonlySet<string>): readonly FileTreeEntry[];
|
|
17
|
+
export declare function fuzzyScore(path: string, query: string): number | undefined;
|
|
18
|
+
export declare function fuzzyFiles(files: readonly FileEntry[], query: string, limit?: number): readonly FileEntry[];
|
|
19
|
+
export declare function syntaxTokens(line: string, path: string): readonly SyntaxToken[];
|
|
20
|
+
export declare function clipSyntaxTokens(tokens: readonly SyntaxToken[], maximumCharacters: number): readonly SyntaxToken[];
|
|
21
|
+
export declare function wrapSyntaxTokens(tokens: readonly SyntaxToken[], maximumCharacters: number): readonly (readonly SyntaxToken[])[];
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { extname } from "node:path";
|
|
2
|
+
export function buildFileTree(files, collapsed) {
|
|
3
|
+
const root = { directories: new Map(), files: [] };
|
|
4
|
+
for (const file of files) {
|
|
5
|
+
const parts = file.path.replaceAll("\\", "/").split("/").filter(Boolean);
|
|
6
|
+
const fileName = parts.pop();
|
|
7
|
+
if (!fileName)
|
|
8
|
+
continue;
|
|
9
|
+
let node = root;
|
|
10
|
+
for (const part of parts) {
|
|
11
|
+
let child = node.directories.get(part);
|
|
12
|
+
if (!child) {
|
|
13
|
+
child = { directories: new Map(), files: [] };
|
|
14
|
+
node.directories.set(part, child);
|
|
15
|
+
}
|
|
16
|
+
node = child;
|
|
17
|
+
}
|
|
18
|
+
node.files.push({ path: [...parts, fileName].join("/") });
|
|
19
|
+
}
|
|
20
|
+
const result = [];
|
|
21
|
+
const append = (node, parentPath, depth) => {
|
|
22
|
+
const directories = [...node.directories.entries()].sort(([left], [right]) => left.localeCompare(right));
|
|
23
|
+
for (const [name, child] of directories) {
|
|
24
|
+
const path = parentPath ? `${parentPath}/${name}` : name;
|
|
25
|
+
const expanded = !collapsed.has(path);
|
|
26
|
+
result.push({ kind: "directory", path, name, depth, expanded });
|
|
27
|
+
if (expanded)
|
|
28
|
+
append(child, path, depth + 1);
|
|
29
|
+
}
|
|
30
|
+
for (const file of [...node.files].sort((left, right) => left.path.localeCompare(right.path))) {
|
|
31
|
+
result.push({
|
|
32
|
+
kind: "file",
|
|
33
|
+
path: file.path,
|
|
34
|
+
name: file.path.split("/").at(-1) ?? file.path,
|
|
35
|
+
depth
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
append(root, "", 0);
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
42
|
+
export function fuzzyScore(path, query) {
|
|
43
|
+
const target = path.toLocaleLowerCase();
|
|
44
|
+
const needle = query.trim().toLocaleLowerCase();
|
|
45
|
+
if (!needle)
|
|
46
|
+
return 0;
|
|
47
|
+
let position = 0;
|
|
48
|
+
let score = 0;
|
|
49
|
+
for (const character of needle) {
|
|
50
|
+
const next = target.indexOf(character, position);
|
|
51
|
+
if (next === -1)
|
|
52
|
+
return undefined;
|
|
53
|
+
score += next - position;
|
|
54
|
+
position = next + 1;
|
|
55
|
+
}
|
|
56
|
+
const fileName = target.split("/").at(-1) ?? target;
|
|
57
|
+
if (fileName.startsWith(needle))
|
|
58
|
+
score -= 60;
|
|
59
|
+
else if (fileName.includes(needle))
|
|
60
|
+
score -= 35;
|
|
61
|
+
else if (target.includes(needle))
|
|
62
|
+
score -= 20;
|
|
63
|
+
return score + path.length / 1_000;
|
|
64
|
+
}
|
|
65
|
+
export function fuzzyFiles(files, query, limit = 12) {
|
|
66
|
+
return files
|
|
67
|
+
.flatMap((file) => {
|
|
68
|
+
const score = fuzzyScore(file.path, query);
|
|
69
|
+
return score === undefined ? [] : [{ file, score }];
|
|
70
|
+
})
|
|
71
|
+
.sort((left, right) => left.score - right.score || left.file.path.localeCompare(right.file.path))
|
|
72
|
+
.slice(0, limit)
|
|
73
|
+
.map(({ file }) => file);
|
|
74
|
+
}
|
|
75
|
+
const codeExtensions = new Set([".c", ".cc", ".cpp", ".cs", ".go", ".java", ".js", ".jsx", ".kt", ".mjs", ".php", ".py", ".rb", ".rs", ".sh", ".ts", ".tsx"]);
|
|
76
|
+
const markupExtensions = new Set([".html", ".htm", ".svg", ".vue", ".svelte", ".xml"]);
|
|
77
|
+
const dataExtensions = new Set([".json", ".jsonc", ".yaml", ".yml", ".toml"]);
|
|
78
|
+
function tokenize(line, pattern, colors) {
|
|
79
|
+
const result = [];
|
|
80
|
+
let cursor = 0;
|
|
81
|
+
for (const match of line.matchAll(pattern)) {
|
|
82
|
+
const index = match.index ?? 0;
|
|
83
|
+
if (index > cursor)
|
|
84
|
+
result.push({ text: line.slice(cursor, index) });
|
|
85
|
+
const group = match.slice(1).findIndex((value) => value !== undefined);
|
|
86
|
+
result.push({ text: match[0], color: colors[Math.max(0, group)], dim: group === 0 });
|
|
87
|
+
cursor = index + match[0].length;
|
|
88
|
+
}
|
|
89
|
+
if (cursor < line.length)
|
|
90
|
+
result.push({ text: line.slice(cursor) });
|
|
91
|
+
return result.length ? result : [{ text: line }];
|
|
92
|
+
}
|
|
93
|
+
export function syntaxTokens(line, path) {
|
|
94
|
+
const extension = extname(path).toLowerCase();
|
|
95
|
+
if (markupExtensions.has(extension)) {
|
|
96
|
+
return tokenize(line, /(<!--.*?-->)|(<\/?[A-Za-z][^>]*>)|("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')/g, ["gray", "cyan", "green"]);
|
|
97
|
+
}
|
|
98
|
+
if (extension === ".md" || extension === ".mdx") {
|
|
99
|
+
return tokenize(line, /(^\s*#{1,6}\s.*$)|(`[^`]*`)|(\*\*[^*]+\*\*)|(\[[^\]]+\]\([^)]+\))/g, ["cyan", "green", "yellow", "blue"]);
|
|
100
|
+
}
|
|
101
|
+
if (dataExtensions.has(extension)) {
|
|
102
|
+
return tokenize(line, /(^\s*(?:#|\/\/).*$)|("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')|\b(true|false|null)\b|(-?\b\d+(?:\.\d+)?\b)|([{}[\],:=])/g, ["gray", "green", "magenta", "yellow", "cyan"]);
|
|
103
|
+
}
|
|
104
|
+
if (codeExtensions.has(extension)) {
|
|
105
|
+
const comment = extension === ".py" || extension === ".rb" || extension === ".sh"
|
|
106
|
+
? /(#.*$)|("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`)|(\b(?:as|async|await|break|case|catch|class|const|continue|def|default|delete|do|else|enum|export|extends|false|finally|fn|for|from|function|if|implements|import|in|instanceof|interface|let|match|new|null|package|private|protected|public|return|static|struct|super|switch|this|throw|true|try|type|typeof|undefined|use|var|void|while|yield)\b)|(\b(?:0x[\da-f]+|\d+(?:\.\d+)?)\b)|([{}()[\].,:;=+\-*/<>!?&|]+)/gi
|
|
107
|
+
: /(\/\/.*$)|("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`)|(\b(?:as|async|await|break|case|catch|class|const|continue|def|default|delete|do|else|enum|export|extends|false|finally|fn|for|from|function|if|implements|import|in|instanceof|interface|let|match|new|null|package|private|protected|public|return|static|struct|super|switch|this|throw|true|try|type|typeof|undefined|use|var|void|while|yield)\b)|(\b(?:0x[\da-f]+|\d+(?:\.\d+)?)\b)|([{}()[\].,:;=+\-*/<>!?&|]+)/gi;
|
|
108
|
+
return tokenize(line, comment, ["gray", "green", "magenta", "yellow", "cyan"]);
|
|
109
|
+
}
|
|
110
|
+
return [{ text: line }];
|
|
111
|
+
}
|
|
112
|
+
export function clipSyntaxTokens(tokens, maximumCharacters) {
|
|
113
|
+
const result = [];
|
|
114
|
+
let remaining = Math.max(0, maximumCharacters);
|
|
115
|
+
for (const token of tokens) {
|
|
116
|
+
if (!remaining)
|
|
117
|
+
break;
|
|
118
|
+
const text = token.text.slice(0, remaining);
|
|
119
|
+
if (text)
|
|
120
|
+
result.push({ ...token, text });
|
|
121
|
+
remaining -= text.length;
|
|
122
|
+
}
|
|
123
|
+
return result;
|
|
124
|
+
}
|
|
125
|
+
export function wrapSyntaxTokens(tokens, maximumCharacters) {
|
|
126
|
+
const width = Math.max(1, maximumCharacters);
|
|
127
|
+
const rows = [[]];
|
|
128
|
+
let rowLength = 0;
|
|
129
|
+
for (const token of tokens) {
|
|
130
|
+
let remaining = token.text;
|
|
131
|
+
if (!remaining && rows.length === 1 && !rows[0].length)
|
|
132
|
+
rows[0].push(token);
|
|
133
|
+
while (remaining) {
|
|
134
|
+
const available = width - rowLength;
|
|
135
|
+
if (!available) {
|
|
136
|
+
rows.push([]);
|
|
137
|
+
rowLength = 0;
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const text = remaining.slice(0, available);
|
|
141
|
+
rows.at(-1)?.push({ ...token, text });
|
|
142
|
+
rowLength += text.length;
|
|
143
|
+
remaining = remaining.slice(text.length);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return rows;
|
|
147
|
+
}
|
package/logo.png
ADDED
|
Binary file
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@truss-harness/tui",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Interactive terminal client for Truss.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"truss-tui": "./dist/bin.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "tsc -b",
|
|
11
|
+
"prepack": "node ../../scripts/clean-publish-tests.mjs",
|
|
12
|
+
"prepublishOnly": "npm run build"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist",
|
|
16
|
+
"README.md",
|
|
17
|
+
"logo.png"
|
|
18
|
+
],
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=20"
|
|
21
|
+
},
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "git+https://github.com/truss-agent/truss-harness.git"
|
|
26
|
+
},
|
|
27
|
+
"bugs": {
|
|
28
|
+
"url": "https://github.com/truss-agent/truss-harness/issues"
|
|
29
|
+
},
|
|
30
|
+
"homepage": "https://github.com/truss-agent/truss-harness#readme",
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public"
|
|
33
|
+
},
|
|
34
|
+
"keywords": [
|
|
35
|
+
"ai",
|
|
36
|
+
"coding-agent",
|
|
37
|
+
"tui",
|
|
38
|
+
"terminal",
|
|
39
|
+
"ollama",
|
|
40
|
+
"local-first"
|
|
41
|
+
],
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"ink": "^6.8.0",
|
|
44
|
+
"react": "^19.2.0",
|
|
45
|
+
"@truss-harness/branding": "0.1.0",
|
|
46
|
+
"@truss-harness/cli": "0.1.0",
|
|
47
|
+
"@truss-harness/provider-openai-compatible": "0.1.0",
|
|
48
|
+
"@truss-harness/runtime": "0.1.0"
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@types/react": "^19.2.0"
|
|
52
|
+
}
|
|
53
|
+
}
|