@truss-harness/tui 0.1.14 → 0.1.18
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 -1
- package/dist/bin.js +151 -966
- package/dist/components.d.ts +44 -0
- package/dist/components.js +60 -0
- package/dist/display.d.ts +10 -0
- package/dist/display.js +88 -0
- package/dist/input-controller.d.ts +74 -0
- package/dist/input-controller.js +251 -0
- package/dist/layout.d.ts +23 -0
- package/dist/layout.js +39 -0
- package/dist/processes.d.ts +7 -0
- package/dist/processes.js +65 -0
- package/dist/runtime-session.d.ts +40 -0
- package/dist/runtime-session.js +287 -0
- package/dist/terminal-controller.d.ts +15 -0
- package/dist/terminal-controller.js +57 -0
- package/dist/types.d.ts +26 -0
- package/dist/types.js +1 -0
- package/dist/workspace-files.d.ts +35 -0
- package/dist/workspace-files.js +169 -0
- package/dist/workspace-view.d.ts +37 -0
- package/dist/workspace-view.js +72 -0
- package/package.json +3 -3
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { execFile as execFileCallback, spawn, } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
const execFile = promisify(execFileCallback);
|
|
4
|
+
export async function runTrackedCommand(command, workspaceRoot, onOutput, onProcess) {
|
|
5
|
+
await new Promise((resolve, reject) => {
|
|
6
|
+
const child = spawn(command, {
|
|
7
|
+
cwd: workspaceRoot,
|
|
8
|
+
shell: true,
|
|
9
|
+
windowsHide: true,
|
|
10
|
+
});
|
|
11
|
+
onProcess?.(child);
|
|
12
|
+
child.stdout.on("data", (data) => onOutput(data.toString()));
|
|
13
|
+
child.stderr.on("data", (data) => onOutput(data.toString()));
|
|
14
|
+
child.on("error", reject);
|
|
15
|
+
child.on("close", (code) => {
|
|
16
|
+
onProcess?.(undefined);
|
|
17
|
+
onOutput(`\n[process exited: ${code ?? "unknown"}]\n`);
|
|
18
|
+
resolve();
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
export function detectedPreviewUrl(output) {
|
|
23
|
+
const match = output.match(/https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::\d+)?(?:\/[^\s]*)?/i);
|
|
24
|
+
return match?.[0].replace(/[),.;]+$/, "").replace("0.0.0.0", "127.0.0.1");
|
|
25
|
+
}
|
|
26
|
+
export function normalizedPreviewUrl(value) {
|
|
27
|
+
const normalized = /^[a-z][a-z\d+.-]*:\/\//i.test(value.trim())
|
|
28
|
+
? value.trim()
|
|
29
|
+
: `http://${value.trim()}`;
|
|
30
|
+
const url = new URL(normalized);
|
|
31
|
+
if (url.protocol !== "http:" && url.protocol !== "https:")
|
|
32
|
+
throw new Error("Preview URLs must use HTTP or HTTPS.");
|
|
33
|
+
return url.toString();
|
|
34
|
+
}
|
|
35
|
+
export function openExternalPreview(value) {
|
|
36
|
+
const url = normalizedPreviewUrl(value);
|
|
37
|
+
const [command, arguments_] = process.platform === "win32"
|
|
38
|
+
? ["rundll32.exe", ["url.dll,FileProtocolHandler", url]]
|
|
39
|
+
: process.platform === "darwin"
|
|
40
|
+
? ["open", [url]]
|
|
41
|
+
: ["xdg-open", [url]];
|
|
42
|
+
const child = spawn(command, arguments_, {
|
|
43
|
+
detached: true,
|
|
44
|
+
stdio: "ignore",
|
|
45
|
+
windowsHide: true,
|
|
46
|
+
});
|
|
47
|
+
child.once("error", () => undefined);
|
|
48
|
+
child.unref();
|
|
49
|
+
}
|
|
50
|
+
export async function readWorkingTreeDiff(workspaceRoot, path) {
|
|
51
|
+
const { stdout } = await execFile("git", ["diff", "--no-ext-diff", "--", path], { cwd: workspaceRoot, maxBuffer: 1_000_000 });
|
|
52
|
+
return stdout;
|
|
53
|
+
}
|
|
54
|
+
export async function stopProcessTree(child) {
|
|
55
|
+
if (process.platform === "win32" && child.pid) {
|
|
56
|
+
try {
|
|
57
|
+
await execFile("taskkill", ["/PID", String(child.pid), "/T", "/F"]);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// Fall back to the Node process handle if taskkill is unavailable.
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
child.kill();
|
|
65
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ResolvedConfiguration } from "@truss-harness/cli/config";
|
|
2
|
+
import { type ClientConfiguration } from "@truss-harness/cli/runtime";
|
|
3
|
+
import { type ModelProviderKind } from "@truss-harness/provider-openai-compatible";
|
|
4
|
+
import { type ToolCall, type WorkspacePlan } from "@truss-harness/runtime";
|
|
5
|
+
import type { ChatMessage, RunStatus, Screen } from "./types.js";
|
|
6
|
+
export interface RuntimeSessionOptions {
|
|
7
|
+
readonly initialConfiguration?: ResolvedConfiguration;
|
|
8
|
+
readonly workspaceRoot: string;
|
|
9
|
+
readonly providerKind: ModelProviderKind;
|
|
10
|
+
readonly endpointInput: string;
|
|
11
|
+
readonly modelInput: string;
|
|
12
|
+
readonly agentMode: ClientConfiguration["mode"];
|
|
13
|
+
readonly permissionMode: ResolvedConfiguration["permission"];
|
|
14
|
+
readonly internetAccess: boolean;
|
|
15
|
+
readonly openFilePath?: string;
|
|
16
|
+
readonly contextWindow: number;
|
|
17
|
+
readonly appendTerminal: (output: string) => void;
|
|
18
|
+
readonly showScreen: (screen: Screen) => void;
|
|
19
|
+
}
|
|
20
|
+
export declare function useRuntimeSessionController({ initialConfiguration, workspaceRoot, providerKind, endpointInput, modelInput, agentMode, permissionMode, internetAccess, openFilePath, contextWindow, appendTerminal, showScreen, }: RuntimeSessionOptions): {
|
|
21
|
+
configuration: ClientConfiguration | undefined;
|
|
22
|
+
mcpStatuses: readonly import("@truss-harness/mcp").McpServerStatus[];
|
|
23
|
+
sessionId: string | undefined;
|
|
24
|
+
chatInput: string;
|
|
25
|
+
setChatInput: import("react").Dispatch<import("react").SetStateAction<string>>;
|
|
26
|
+
chat: ChatMessage[];
|
|
27
|
+
chatScroll: number;
|
|
28
|
+
setChatScroll: import("react").Dispatch<import("react").SetStateAction<number>>;
|
|
29
|
+
busy: boolean;
|
|
30
|
+
runStatus: RunStatus;
|
|
31
|
+
activePlan: WorkspacePlan | undefined;
|
|
32
|
+
pendingTool: ToolCall | undefined;
|
|
33
|
+
tokensPerSecond: number | undefined;
|
|
34
|
+
configureRuntime: () => Promise<void>;
|
|
35
|
+
sendPrompt: () => Promise<void>;
|
|
36
|
+
resolveApproval: (approved: boolean) => void;
|
|
37
|
+
startNewConversation: () => void;
|
|
38
|
+
cancelRun: () => void;
|
|
39
|
+
testMcpConnections: () => void;
|
|
40
|
+
};
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { createClientRuntime, } from "@truss-harness/cli/runtime";
|
|
4
|
+
import { cloudProviderDefinition, isCloudProviderId, } from "@truss-harness/provider-openai-compatible";
|
|
5
|
+
import { executeWorkspaceCommand, FileWorkspacePlanStore, } from "@truss-harness/runtime";
|
|
6
|
+
import { useEffect, useRef, useState } from "react";
|
|
7
|
+
import { estimateTokens, truncate } from "./display.js";
|
|
8
|
+
export function useRuntimeSessionController({ initialConfiguration, workspaceRoot, providerKind, endpointInput, modelInput, agentMode, permissionMode, internetAccess, openFilePath, contextWindow, appendTerminal, showScreen, }) {
|
|
9
|
+
const [configuration, setConfiguration] = useState(initialConfiguration);
|
|
10
|
+
const [client, setClient] = useState();
|
|
11
|
+
const [mcpStatuses, setMcpStatuses] = useState(() => Object.entries(initialConfiguration?.mcpServers ?? {}).map(([name, server]) => ({
|
|
12
|
+
name,
|
|
13
|
+
state: server.enabled === false ? "disabled" : "idle",
|
|
14
|
+
toolCount: 0,
|
|
15
|
+
})));
|
|
16
|
+
const [sessionId, setSessionId] = useState();
|
|
17
|
+
const [chatInput, setChatInput] = useState("");
|
|
18
|
+
const [chat, setChat] = useState([]);
|
|
19
|
+
const [chatScroll, setChatScroll] = useState(0);
|
|
20
|
+
const [streamMetrics, setStreamMetrics] = useState({
|
|
21
|
+
startedAt: 0,
|
|
22
|
+
generatedTokens: 0,
|
|
23
|
+
});
|
|
24
|
+
const [busy, setBusy] = useState(false);
|
|
25
|
+
const [runStatus, setRunStatus] = useState("ready");
|
|
26
|
+
const [activePlan, setActivePlan] = useState();
|
|
27
|
+
const [pendingTool, setPendingTool] = useState();
|
|
28
|
+
const approvalResolvers = useRef(new Map());
|
|
29
|
+
const abortController = useRef(undefined);
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
void new FileWorkspacePlanStore(workspaceRoot)
|
|
32
|
+
.load()
|
|
33
|
+
.then(setActivePlan)
|
|
34
|
+
.catch(() => undefined);
|
|
35
|
+
}, [workspaceRoot]);
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
if (!client)
|
|
38
|
+
return;
|
|
39
|
+
const unsubscribeMcp = client.mcp.subscribe((statuses) => setMcpStatuses((current) => {
|
|
40
|
+
const merged = new Map(current.map((status) => [status.name, status]));
|
|
41
|
+
for (const status of statuses)
|
|
42
|
+
merged.set(status.name, status);
|
|
43
|
+
return [...merged.values()];
|
|
44
|
+
}));
|
|
45
|
+
const unsubscribe = client.events.subscribe((event) => {
|
|
46
|
+
if (event.type === "text_delta") {
|
|
47
|
+
setRunStatus("thinking");
|
|
48
|
+
setChatScroll(0);
|
|
49
|
+
setStreamMetrics((current) => current.startedAt
|
|
50
|
+
? {
|
|
51
|
+
...current,
|
|
52
|
+
generatedTokens: current.generatedTokens + estimateTokens(event.text),
|
|
53
|
+
}
|
|
54
|
+
: current);
|
|
55
|
+
setChat((current) => {
|
|
56
|
+
const last = current.at(-1);
|
|
57
|
+
if (last?.role !== "assistant")
|
|
58
|
+
return [...current, { role: "assistant", content: event.text }];
|
|
59
|
+
return [
|
|
60
|
+
...current.slice(0, -1),
|
|
61
|
+
{ role: "assistant", content: last.content + event.text },
|
|
62
|
+
];
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
if (event.type === "tool_call_requested") {
|
|
66
|
+
setRunStatus("tool");
|
|
67
|
+
appendTerminal(`[tool requested] ${event.tool} ${JSON.stringify(event.input)}`);
|
|
68
|
+
}
|
|
69
|
+
if (event.type === "tool_completed") {
|
|
70
|
+
setRunStatus("thinking");
|
|
71
|
+
appendTerminal(`[tool completed] ${event.tool}: ${truncate(event.result.content, 160)}`);
|
|
72
|
+
}
|
|
73
|
+
if (event.type === "plan_updated")
|
|
74
|
+
setActivePlan(event.plan);
|
|
75
|
+
if (event.type === "run_failed") {
|
|
76
|
+
setRunStatus("ready");
|
|
77
|
+
appendTerminal(`[agent error] ${event.error.message}`);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
return () => {
|
|
81
|
+
unsubscribe();
|
|
82
|
+
unsubscribeMcp();
|
|
83
|
+
void client.dispose();
|
|
84
|
+
};
|
|
85
|
+
}, [client, appendTerminal]);
|
|
86
|
+
const configureRuntime = async () => {
|
|
87
|
+
if (!endpointInput.trim() || !modelInput.trim()) {
|
|
88
|
+
appendTerminal("[configuration] An endpoint and model are required.");
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const nextConfiguration = {
|
|
92
|
+
workspaceRoot,
|
|
93
|
+
provider: providerKind,
|
|
94
|
+
baseUrl: endpointInput.trim(),
|
|
95
|
+
model: modelInput.trim(),
|
|
96
|
+
apiKey: initialConfiguration?.provider === providerKind
|
|
97
|
+
? initialConfiguration.apiKey
|
|
98
|
+
: (process.env.TRUSS_HARNESS_API_KEY ??
|
|
99
|
+
(isCloudProviderId(providerKind)
|
|
100
|
+
? process.env[cloudProviderDefinition(providerKind)
|
|
101
|
+
.apiKeyEnvironmentVariable]
|
|
102
|
+
: undefined)),
|
|
103
|
+
systemPrompt: process.env.TRUSS_HARNESS_SYSTEM_PROMPT,
|
|
104
|
+
mode: agentMode,
|
|
105
|
+
internetAccess,
|
|
106
|
+
mcpServers: initialConfiguration?.mcpServers,
|
|
107
|
+
approval: {
|
|
108
|
+
approve: async (call) => {
|
|
109
|
+
const readOnly = [
|
|
110
|
+
"read_file",
|
|
111
|
+
"list_directory",
|
|
112
|
+
"search_files",
|
|
113
|
+
"grep",
|
|
114
|
+
].includes(call.name);
|
|
115
|
+
if (permissionMode === "auto-all" ||
|
|
116
|
+
(permissionMode === "auto-read" && readOnly))
|
|
117
|
+
return true;
|
|
118
|
+
return new Promise((resolve) => {
|
|
119
|
+
approvalResolvers.current.set(call.id, resolve);
|
|
120
|
+
setPendingTool(call);
|
|
121
|
+
setRunStatus("waiting");
|
|
122
|
+
showScreen("approval");
|
|
123
|
+
});
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
setConfiguration(nextConfiguration);
|
|
128
|
+
setRunStatus("thinking");
|
|
129
|
+
try {
|
|
130
|
+
const nextClient = await createClientRuntime(nextConfiguration);
|
|
131
|
+
setClient(nextClient);
|
|
132
|
+
for (const server of nextClient.mcpServers) {
|
|
133
|
+
appendTerminal(`[mcp] ${server.name}: ${server.state}${server.error ? ` (${server.error})` : ` (${server.toolCount} tools)`}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
appendTerminal(`[mcp] Unable to configure runtime: ${error instanceof Error ? error.message : String(error)}`);
|
|
138
|
+
setRunStatus("ready");
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
setSessionId(undefined);
|
|
142
|
+
setChat([]);
|
|
143
|
+
setChatScroll(0);
|
|
144
|
+
setStreamMetrics({ startedAt: 0, generatedTokens: 0 });
|
|
145
|
+
showScreen("workspace");
|
|
146
|
+
appendTerminal(`[configuration] ${nextConfiguration.provider} ${nextConfiguration.model} at ${nextConfiguration.baseUrl}`);
|
|
147
|
+
setRunStatus("ready");
|
|
148
|
+
};
|
|
149
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: startup configuration intentionally runs exactly once; subsequent changes are applied explicitly.
|
|
150
|
+
useEffect(() => {
|
|
151
|
+
if (initialConfiguration && !client)
|
|
152
|
+
void configureRuntime();
|
|
153
|
+
}, []);
|
|
154
|
+
const openFileContext = async () => {
|
|
155
|
+
if (!openFilePath)
|
|
156
|
+
return [];
|
|
157
|
+
try {
|
|
158
|
+
const maximumCharacters = Math.max(2_000, Math.min(20_000, contextWindow));
|
|
159
|
+
const content = await readFile(join(workspaceRoot, openFilePath), "utf8");
|
|
160
|
+
const clipped = content.slice(0, maximumCharacters);
|
|
161
|
+
return [
|
|
162
|
+
{
|
|
163
|
+
source: `active-file:${openFilePath}`,
|
|
164
|
+
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}`,
|
|
165
|
+
priority: 1_000,
|
|
166
|
+
},
|
|
167
|
+
];
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
return [];
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
const sendPrompt = async () => {
|
|
174
|
+
const prompt = chatInput.trim();
|
|
175
|
+
if (!prompt || busy)
|
|
176
|
+
return;
|
|
177
|
+
const command = await executeWorkspaceCommand({
|
|
178
|
+
workspaceRoot,
|
|
179
|
+
input: prompt,
|
|
180
|
+
});
|
|
181
|
+
if (command.handled) {
|
|
182
|
+
setChatInput("");
|
|
183
|
+
setChat((current) => [
|
|
184
|
+
...current,
|
|
185
|
+
{ role: "user", content: prompt },
|
|
186
|
+
{ role: "assistant", content: command.message },
|
|
187
|
+
]);
|
|
188
|
+
appendTerminal(`[workspace command] ${command.command ?? prompt}: ${command.ok ? "completed" : "failed"}`);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
if (!client) {
|
|
192
|
+
showScreen("settings");
|
|
193
|
+
appendTerminal("[configuration] Select a local model before sending a prompt.");
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
setChatInput("");
|
|
197
|
+
setChatScroll(0);
|
|
198
|
+
setBusy(true);
|
|
199
|
+
setRunStatus("thinking");
|
|
200
|
+
setStreamMetrics({ startedAt: Date.now(), generatedTokens: 0 });
|
|
201
|
+
setChat((current) => [
|
|
202
|
+
...current,
|
|
203
|
+
{ role: "user", content: prompt },
|
|
204
|
+
{ role: "assistant", content: "" },
|
|
205
|
+
]);
|
|
206
|
+
const controller = new AbortController();
|
|
207
|
+
abortController.current = controller;
|
|
208
|
+
try {
|
|
209
|
+
const session = sessionId
|
|
210
|
+
? await client.runtime.getSession(sessionId)
|
|
211
|
+
: await client.runtime.createSession();
|
|
212
|
+
if (!session)
|
|
213
|
+
throw new Error("Conversation is unavailable.");
|
|
214
|
+
setSessionId(session.id);
|
|
215
|
+
await client.runtime.run(session.id, prompt, controller.signal, await openFileContext());
|
|
216
|
+
}
|
|
217
|
+
catch (error) {
|
|
218
|
+
if (!controller.signal.aborted)
|
|
219
|
+
appendTerminal(`[agent error] ${error instanceof Error ? error.message : String(error)}`);
|
|
220
|
+
}
|
|
221
|
+
finally {
|
|
222
|
+
setBusy(false);
|
|
223
|
+
setRunStatus("ready");
|
|
224
|
+
abortController.current = undefined;
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
const resolveApproval = (approved) => {
|
|
228
|
+
if (pendingTool)
|
|
229
|
+
approvalResolvers.current.get(pendingTool.id)?.(approved);
|
|
230
|
+
setPendingTool(undefined);
|
|
231
|
+
setRunStatus(approved ? "thinking" : "ready");
|
|
232
|
+
showScreen("workspace");
|
|
233
|
+
};
|
|
234
|
+
const startNewConversation = () => {
|
|
235
|
+
if (busy) {
|
|
236
|
+
appendTerminal("[conversation] Stop the active run before starting a new conversation.");
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
setSessionId(undefined);
|
|
240
|
+
setChat([]);
|
|
241
|
+
setChatInput("");
|
|
242
|
+
setChatScroll(0);
|
|
243
|
+
setStreamMetrics({ startedAt: 0, generatedTokens: 0 });
|
|
244
|
+
appendTerminal("[conversation] Started a new conversation.");
|
|
245
|
+
};
|
|
246
|
+
const cancelRun = () => {
|
|
247
|
+
abortController.current?.abort();
|
|
248
|
+
setRunStatus("ready");
|
|
249
|
+
appendTerminal("[agent] Cancellation requested.");
|
|
250
|
+
};
|
|
251
|
+
const testMcpConnections = () => {
|
|
252
|
+
if (!configuration)
|
|
253
|
+
return;
|
|
254
|
+
void createClientRuntime({ ...configuration, mode: "edit" })
|
|
255
|
+
.then(async (inspection) => {
|
|
256
|
+
setMcpStatuses(inspection.mcpServers);
|
|
257
|
+
appendTerminal("[mcp] Tested configured servers.");
|
|
258
|
+
await inspection.dispose();
|
|
259
|
+
})
|
|
260
|
+
.catch((error) => appendTerminal(`[mcp] ${error instanceof Error ? error.message : String(error)}`));
|
|
261
|
+
};
|
|
262
|
+
const tokensPerSecond = streamMetrics.startedAt && streamMetrics.generatedTokens
|
|
263
|
+
? streamMetrics.generatedTokens /
|
|
264
|
+
Math.max((Date.now() - streamMetrics.startedAt) / 1_000, 0.1)
|
|
265
|
+
: undefined;
|
|
266
|
+
return {
|
|
267
|
+
configuration,
|
|
268
|
+
mcpStatuses,
|
|
269
|
+
sessionId,
|
|
270
|
+
chatInput,
|
|
271
|
+
setChatInput,
|
|
272
|
+
chat,
|
|
273
|
+
chatScroll,
|
|
274
|
+
setChatScroll,
|
|
275
|
+
busy,
|
|
276
|
+
runStatus,
|
|
277
|
+
activePlan,
|
|
278
|
+
pendingTool,
|
|
279
|
+
tokensPerSecond,
|
|
280
|
+
configureRuntime,
|
|
281
|
+
sendPrompt,
|
|
282
|
+
resolveApproval,
|
|
283
|
+
startNewConversation,
|
|
284
|
+
cancelRun,
|
|
285
|
+
testMcpConnections,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface TerminalLine {
|
|
2
|
+
readonly id: number;
|
|
3
|
+
readonly text: string;
|
|
4
|
+
}
|
|
5
|
+
export declare function useTerminalController(workspaceRoot: string): {
|
|
6
|
+
terminalLines: TerminalLine[];
|
|
7
|
+
terminalScroll: number;
|
|
8
|
+
setTerminalScroll: import("react").Dispatch<import("react").SetStateAction<number>>;
|
|
9
|
+
commandInput: string;
|
|
10
|
+
setCommandInput: import("react").Dispatch<import("react").SetStateAction<string>>;
|
|
11
|
+
previewUrl: string | undefined;
|
|
12
|
+
appendTerminal: (output: string) => void;
|
|
13
|
+
runTerminalInput: (input: string) => Promise<void>;
|
|
14
|
+
interruptProcess: () => boolean;
|
|
15
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { executeWorkspaceCommand } from "@truss-harness/runtime";
|
|
2
|
+
import { useCallback, useRef, useState } from "react";
|
|
3
|
+
import { detectedPreviewUrl, runTrackedCommand, stopProcessTree, } from "./processes.js";
|
|
4
|
+
export function useTerminalController(workspaceRoot) {
|
|
5
|
+
const nextLineId = useRef(1);
|
|
6
|
+
const [terminalLines, setTerminalLines] = useState([
|
|
7
|
+
{
|
|
8
|
+
id: 0,
|
|
9
|
+
text: "Terminal ready. Shell commands run in the workspace root; slash commands run locally.",
|
|
10
|
+
},
|
|
11
|
+
]);
|
|
12
|
+
const [terminalScroll, setTerminalScroll] = useState(0);
|
|
13
|
+
const [commandInput, setCommandInput] = useState("");
|
|
14
|
+
const [previewUrl, setPreviewUrl] = useState();
|
|
15
|
+
const terminalProcess = useRef(undefined);
|
|
16
|
+
const appendTerminal = useCallback((output) => {
|
|
17
|
+
const next = output.replace(/\r/g, "").split("\n").filter(Boolean);
|
|
18
|
+
if (!next.length)
|
|
19
|
+
return;
|
|
20
|
+
const detected = detectedPreviewUrl(output);
|
|
21
|
+
if (detected)
|
|
22
|
+
setPreviewUrl(detected);
|
|
23
|
+
setTerminalScroll(0);
|
|
24
|
+
const entries = next.map((text) => ({ id: nextLineId.current++, text }));
|
|
25
|
+
setTerminalLines((current) => [...current, ...entries].slice(-110));
|
|
26
|
+
}, []);
|
|
27
|
+
const runTerminalInput = async (input) => {
|
|
28
|
+
const command = await executeWorkspaceCommand({ workspaceRoot, input });
|
|
29
|
+
if (command.handled) {
|
|
30
|
+
appendTerminal(`[workspace command] ${command.command ?? input}: ${command.ok ? "completed" : "failed"}`);
|
|
31
|
+
appendTerminal(command.message);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
await runTrackedCommand(input, workspaceRoot, appendTerminal, (process) => {
|
|
35
|
+
terminalProcess.current = process;
|
|
36
|
+
});
|
|
37
|
+
};
|
|
38
|
+
const interruptProcess = () => {
|
|
39
|
+
if (!terminalProcess.current)
|
|
40
|
+
return false;
|
|
41
|
+
const process = terminalProcess.current;
|
|
42
|
+
terminalProcess.current = undefined;
|
|
43
|
+
void stopProcessTree(process).then(() => appendTerminal("[terminal] Process stopped."));
|
|
44
|
+
return true;
|
|
45
|
+
};
|
|
46
|
+
return {
|
|
47
|
+
terminalLines,
|
|
48
|
+
terminalScroll,
|
|
49
|
+
setTerminalScroll,
|
|
50
|
+
commandInput,
|
|
51
|
+
setCommandInput,
|
|
52
|
+
previewUrl,
|
|
53
|
+
appendTerminal,
|
|
54
|
+
runTerminalInput,
|
|
55
|
+
interruptProcess,
|
|
56
|
+
};
|
|
57
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { SyntaxToken } from "./file-browser.js";
|
|
2
|
+
export declare const focusOrder: readonly ["files", "editor", "chat", "terminal"];
|
|
3
|
+
export type Focus = (typeof focusOrder)[number];
|
|
4
|
+
export type Screen = "workspace" | "settings" | "approval" | "help" | "file-search" | "agents" | "mcp";
|
|
5
|
+
export type SettingsField = "server" | "endpoint" | "model" | "internet" | "theme";
|
|
6
|
+
export type RunStatus = "ready" | "thinking" | "tool" | "waiting";
|
|
7
|
+
export interface ChatMessage {
|
|
8
|
+
readonly role: "user" | "assistant";
|
|
9
|
+
readonly content: string;
|
|
10
|
+
}
|
|
11
|
+
export interface ChatDisplayLine {
|
|
12
|
+
readonly key: string;
|
|
13
|
+
readonly role: ChatMessage["role"];
|
|
14
|
+
readonly text: string;
|
|
15
|
+
readonly header: boolean;
|
|
16
|
+
}
|
|
17
|
+
export interface EditorDisplayRow {
|
|
18
|
+
readonly key: string;
|
|
19
|
+
readonly sourceLine: number;
|
|
20
|
+
readonly continuation: boolean;
|
|
21
|
+
readonly tokens: readonly SyntaxToken[];
|
|
22
|
+
}
|
|
23
|
+
export interface Viewport {
|
|
24
|
+
readonly columns: number;
|
|
25
|
+
readonly rows: number;
|
|
26
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const focusOrder = ["files", "editor", "chat", "terminal"];
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { type FileEntry } from "./file-browser.js";
|
|
2
|
+
import type { EditorDisplayRow } from "./types.js";
|
|
3
|
+
export declare function collectWorkspaceFiles(root: string, current?: string, result?: FileEntry[]): Promise<FileEntry[]>;
|
|
4
|
+
export interface WorkspaceFilesOptions {
|
|
5
|
+
readonly workspaceRoot: string;
|
|
6
|
+
readonly viewportRows: number;
|
|
7
|
+
readonly editorWidth: number;
|
|
8
|
+
readonly editorLineCount: number;
|
|
9
|
+
readonly focusEditor: () => void;
|
|
10
|
+
readonly closeSearch: () => void;
|
|
11
|
+
}
|
|
12
|
+
export declare function useWorkspaceFiles({ workspaceRoot, viewportRows, editorWidth, editorLineCount, focusEditor, closeSearch, }: WorkspaceFilesOptions): {
|
|
13
|
+
files: FileEntry[];
|
|
14
|
+
fileIndex: number;
|
|
15
|
+
setFileIndex: import("react").Dispatch<import("react").SetStateAction<number>>;
|
|
16
|
+
fileTree: readonly import("./file-browser.js").FileTreeEntry[];
|
|
17
|
+
selectedFileTreeEntry: import("./file-browser.js").FileTreeEntry;
|
|
18
|
+
fileSearchInput: string;
|
|
19
|
+
setFileSearchInput: import("react").Dispatch<import("react").SetStateAction<string>>;
|
|
20
|
+
fileSearchIndex: number;
|
|
21
|
+
setFileSearchIndex: import("react").Dispatch<import("react").SetStateAction<number>>;
|
|
22
|
+
fileSearchResults: readonly FileEntry[];
|
|
23
|
+
openFilePath: string | undefined;
|
|
24
|
+
editorTitle: string;
|
|
25
|
+
editorScroll: number;
|
|
26
|
+
setEditorScroll: import("react").Dispatch<import("react").SetStateAction<number>>;
|
|
27
|
+
editorRows: readonly EditorDisplayRow[];
|
|
28
|
+
visibleEditorRows: EditorDisplayRow[];
|
|
29
|
+
fileTreeStart: number;
|
|
30
|
+
visibleFileTree: import("./file-browser.js").FileTreeEntry[];
|
|
31
|
+
loadFile: (entry: FileEntry) => Promise<void>;
|
|
32
|
+
toggleDirectory: (path: string, expand?: boolean) => void;
|
|
33
|
+
openSearchResult: (entry: FileEntry) => void;
|
|
34
|
+
toggleDiff: () => Promise<void>;
|
|
35
|
+
};
|