@truss-harness/tui 0.1.16 → 0.1.19
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/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 +4 -4
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
2
|
+
import { join, relative } from "node:path";
|
|
3
|
+
import { useEffect, useMemo, useState } from "react";
|
|
4
|
+
import { clamp } from "./display.js";
|
|
5
|
+
import { buildFileTree, fuzzyFiles, syntaxTokens, wrapSyntaxTokens, } from "./file-browser.js";
|
|
6
|
+
import { readWorkingTreeDiff } from "./processes.js";
|
|
7
|
+
const ignoredDirectories = new Set([
|
|
8
|
+
".git",
|
|
9
|
+
".next",
|
|
10
|
+
".truss-harness",
|
|
11
|
+
"node_modules",
|
|
12
|
+
"dist",
|
|
13
|
+
"coverage",
|
|
14
|
+
]);
|
|
15
|
+
export async function collectWorkspaceFiles(root, current = root, result = []) {
|
|
16
|
+
if (result.length >= 2_000)
|
|
17
|
+
return result;
|
|
18
|
+
for (const entry of await readdir(current, { withFileTypes: true })) {
|
|
19
|
+
if (result.length >= 2_000)
|
|
20
|
+
break;
|
|
21
|
+
if (entry.isDirectory() && !ignoredDirectories.has(entry.name))
|
|
22
|
+
await collectWorkspaceFiles(root, join(current, entry.name), result);
|
|
23
|
+
if (entry.isFile())
|
|
24
|
+
result.push({ path: relative(root, join(current, entry.name)) });
|
|
25
|
+
}
|
|
26
|
+
return result.sort((left, right) => left.path.localeCompare(right.path));
|
|
27
|
+
}
|
|
28
|
+
export function useWorkspaceFiles({ workspaceRoot, viewportRows, editorWidth, editorLineCount, focusEditor, closeSearch, }) {
|
|
29
|
+
const [files, setFiles] = useState([]);
|
|
30
|
+
const [fileIndex, setFileIndex] = useState(0);
|
|
31
|
+
const [collapsedDirectories, setCollapsedDirectories] = useState(() => new Set());
|
|
32
|
+
const [fileSearchInput, setFileSearchInput] = useState("");
|
|
33
|
+
const [fileSearchIndex, setFileSearchIndex] = useState(0);
|
|
34
|
+
const [openFilePath, setOpenFilePath] = useState();
|
|
35
|
+
const [editor, setEditor] = useState("Select a file from the workspace tree.");
|
|
36
|
+
const [editorTitle, setEditorTitle] = useState("Preview");
|
|
37
|
+
const [isDiff, setIsDiff] = useState(false);
|
|
38
|
+
const [editorScroll, setEditorScroll] = useState(0);
|
|
39
|
+
const fileTree = useMemo(() => buildFileTree(files, collapsedDirectories), [files, collapsedDirectories]);
|
|
40
|
+
const selectedFileTreeEntry = fileTree[Math.min(fileIndex, Math.max(0, fileTree.length - 1))];
|
|
41
|
+
const fileSearchResults = useMemo(() => fuzzyFiles(files, fileSearchInput, Math.max(6, Math.min(14, viewportRows - 10))), [files, fileSearchInput, viewportRows]);
|
|
42
|
+
const editorRows = useMemo(() => {
|
|
43
|
+
const maximumCharacters = Math.max(12, editorWidth - 7);
|
|
44
|
+
return editor.split(/\r?\n/).flatMap((line, sourceIndex) => {
|
|
45
|
+
const color = isDiff
|
|
46
|
+
? line.startsWith("+") && !line.startsWith("+++")
|
|
47
|
+
? "green"
|
|
48
|
+
: line.startsWith("-") && !line.startsWith("---")
|
|
49
|
+
? "red"
|
|
50
|
+
: undefined
|
|
51
|
+
: undefined;
|
|
52
|
+
const tokens = isDiff
|
|
53
|
+
? [{ text: line, color }]
|
|
54
|
+
: syntaxTokens(line, openFilePath ?? "");
|
|
55
|
+
return wrapSyntaxTokens(tokens, maximumCharacters).map((row, rowIndex) => ({
|
|
56
|
+
key: `${sourceIndex}:${rowIndex}`,
|
|
57
|
+
sourceLine: sourceIndex + 1,
|
|
58
|
+
continuation: rowIndex > 0,
|
|
59
|
+
tokens: row,
|
|
60
|
+
}));
|
|
61
|
+
});
|
|
62
|
+
}, [editor, editorWidth, isDiff, openFilePath]);
|
|
63
|
+
const visibleEditorRows = editorRows.slice(editorScroll, editorScroll + editorLineCount);
|
|
64
|
+
const fileTreeStart = clamp(fileIndex - Math.floor(editorLineCount / 2), 0, Math.max(0, fileTree.length - editorLineCount));
|
|
65
|
+
const visibleFileTree = fileTree.slice(fileTreeStart, fileTreeStart + editorLineCount);
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
void collectWorkspaceFiles(workspaceRoot)
|
|
68
|
+
.then(setFiles)
|
|
69
|
+
.catch((error) => setEditor(`Unable to list workspace files: ${String(error)}`));
|
|
70
|
+
}, [workspaceRoot]);
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
setFileIndex((current) => clamp(current, 0, Math.max(0, fileTree.length - 1)));
|
|
73
|
+
}, [fileTree.length]);
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
setEditorScroll((current) => clamp(current, 0, Math.max(0, editorRows.length - editorLineCount)));
|
|
76
|
+
}, [editorRows.length, editorLineCount]);
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
if (!openFilePath)
|
|
79
|
+
return;
|
|
80
|
+
const index = fileTree.findIndex((entry) => entry.kind === "file" && entry.path === openFilePath);
|
|
81
|
+
if (index >= 0)
|
|
82
|
+
setFileIndex(index);
|
|
83
|
+
}, [fileTree, openFilePath]);
|
|
84
|
+
const loadFile = async (entry) => {
|
|
85
|
+
try {
|
|
86
|
+
setIsDiff(false);
|
|
87
|
+
setEditorScroll(0);
|
|
88
|
+
setOpenFilePath(entry.path);
|
|
89
|
+
setEditorTitle(entry.path);
|
|
90
|
+
setEditor(await readFile(join(workspaceRoot, entry.path), "utf8"));
|
|
91
|
+
focusEditor();
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
setEditor(`Unable to read ${entry.path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
const toggleDirectory = (path, expand) => {
|
|
98
|
+
setCollapsedDirectories((current) => {
|
|
99
|
+
const next = new Set(current);
|
|
100
|
+
const currentlyExpanded = !next.has(path);
|
|
101
|
+
const shouldExpand = expand ?? !currentlyExpanded;
|
|
102
|
+
if (shouldExpand)
|
|
103
|
+
next.delete(path);
|
|
104
|
+
else
|
|
105
|
+
next.add(path);
|
|
106
|
+
return next;
|
|
107
|
+
});
|
|
108
|
+
};
|
|
109
|
+
const openSearchResult = (entry) => {
|
|
110
|
+
const parts = entry.path.replaceAll("\\", "/").split("/");
|
|
111
|
+
parts.pop();
|
|
112
|
+
setCollapsedDirectories((current) => {
|
|
113
|
+
const next = new Set(current);
|
|
114
|
+
let path = "";
|
|
115
|
+
for (const part of parts) {
|
|
116
|
+
path = path ? `${path}/${part}` : part;
|
|
117
|
+
next.delete(path);
|
|
118
|
+
}
|
|
119
|
+
return next;
|
|
120
|
+
});
|
|
121
|
+
setFileSearchInput("");
|
|
122
|
+
setFileSearchIndex(0);
|
|
123
|
+
closeSearch();
|
|
124
|
+
void loadFile(entry);
|
|
125
|
+
};
|
|
126
|
+
const toggleDiff = async () => {
|
|
127
|
+
if (!openFilePath)
|
|
128
|
+
return;
|
|
129
|
+
const entry = { path: openFilePath };
|
|
130
|
+
if (isDiff)
|
|
131
|
+
return void loadFile(entry);
|
|
132
|
+
try {
|
|
133
|
+
setOpenFilePath(entry.path);
|
|
134
|
+
const stdout = await readWorkingTreeDiff(workspaceRoot, entry.path);
|
|
135
|
+
setEditorTitle(`Diff: ${entry.path}`);
|
|
136
|
+
setEditor(stdout || "No working-tree diff for this file.");
|
|
137
|
+
setIsDiff(true);
|
|
138
|
+
setEditorScroll(0);
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
setEditor(`Unable to load Git diff: ${error instanceof Error ? error.message : String(error)}`);
|
|
142
|
+
setIsDiff(true);
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
return {
|
|
146
|
+
files,
|
|
147
|
+
fileIndex,
|
|
148
|
+
setFileIndex,
|
|
149
|
+
fileTree,
|
|
150
|
+
selectedFileTreeEntry,
|
|
151
|
+
fileSearchInput,
|
|
152
|
+
setFileSearchInput,
|
|
153
|
+
fileSearchIndex,
|
|
154
|
+
setFileSearchIndex,
|
|
155
|
+
fileSearchResults,
|
|
156
|
+
openFilePath,
|
|
157
|
+
editorTitle,
|
|
158
|
+
editorScroll,
|
|
159
|
+
setEditorScroll,
|
|
160
|
+
editorRows,
|
|
161
|
+
visibleEditorRows,
|
|
162
|
+
fileTreeStart,
|
|
163
|
+
visibleFileTree,
|
|
164
|
+
loadFile,
|
|
165
|
+
toggleDirectory,
|
|
166
|
+
openSearchResult,
|
|
167
|
+
toggleDiff,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { ClientConfiguration } from "@truss-harness/cli/runtime";
|
|
2
|
+
import type { WorkspacePlan } from "@truss-harness/runtime";
|
|
3
|
+
import { type TuiOverlaysProps } from "./components.js";
|
|
4
|
+
import type { FileTreeEntry } from "./file-browser.js";
|
|
5
|
+
import type { TuiLayout } from "./layout.js";
|
|
6
|
+
import type { TerminalLine } from "./terminal-controller.js";
|
|
7
|
+
import type { TuiTheme } from "./theme.js";
|
|
8
|
+
import type { ChatDisplayLine, EditorDisplayRow, Focus, RunStatus, Viewport } from "./types.js";
|
|
9
|
+
export interface TuiWorkspaceViewProps {
|
|
10
|
+
readonly viewport: Viewport;
|
|
11
|
+
readonly layout: TuiLayout;
|
|
12
|
+
readonly theme: TuiTheme;
|
|
13
|
+
readonly configuration?: ClientConfiguration;
|
|
14
|
+
readonly runStatus: RunStatus;
|
|
15
|
+
readonly busy: boolean;
|
|
16
|
+
readonly contextTokens: number;
|
|
17
|
+
readonly contextWindow: number;
|
|
18
|
+
readonly tokensPerSecond?: number;
|
|
19
|
+
readonly previewUrl?: string;
|
|
20
|
+
readonly focus: Focus;
|
|
21
|
+
readonly visibleFileTree: readonly FileTreeEntry[];
|
|
22
|
+
readonly fileTreeLength: number;
|
|
23
|
+
readonly fileTreeStart: number;
|
|
24
|
+
readonly fileIndex: number;
|
|
25
|
+
readonly openFilePath?: string;
|
|
26
|
+
readonly editorTitle: string;
|
|
27
|
+
readonly visibleEditorRows: readonly EditorDisplayRow[];
|
|
28
|
+
readonly activePlan?: WorkspacePlan;
|
|
29
|
+
readonly chatLines: readonly ChatDisplayLine[];
|
|
30
|
+
readonly chatScroll: number;
|
|
31
|
+
readonly chatInput: string;
|
|
32
|
+
readonly visibleTerminalLines: readonly TerminalLine[];
|
|
33
|
+
readonly commandInput: string;
|
|
34
|
+
readonly sessionId?: string;
|
|
35
|
+
readonly overlays: Omit<TuiOverlaysProps, "theme">;
|
|
36
|
+
}
|
|
37
|
+
export declare function TuiWorkspaceView({ viewport, layout, theme, configuration, runStatus, busy, contextTokens, contextWindow, tokensPerSecond, previewUrl, focus, visibleFileTree, fileTreeLength, fileTreeStart, fileIndex, openFilePath, editorTitle, visibleEditorRows, activePlan, chatLines, chatScroll, chatInput, visibleTerminalLines, commandInput, sessionId, overlays, }: TuiWorkspaceViewProps): React.ReactElement;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { brand } from "@truss-harness/branding";
|
|
3
|
+
import { Box, Text } from "ink";
|
|
4
|
+
import { Panel, TuiOverlays } from "./components.js";
|
|
5
|
+
import { formatTokenCount, truncate } from "./display.js";
|
|
6
|
+
export function TuiWorkspaceView({ viewport, layout, theme, configuration, runStatus, busy, contextTokens, contextWindow, tokensPerSecond, previewUrl, focus, visibleFileTree, fileTreeLength, fileTreeStart, fileIndex, openFilePath, editorTitle, visibleEditorRows, activePlan, chatLines, chatScroll, chatInput, visibleTerminalLines, commandInput, sessionId, overlays, }) {
|
|
7
|
+
const { compact, terminalHeight, workspaceHeight, filesWidth, chatWidth, editorWidth, compactChatHeight, editorHeight, } = layout;
|
|
8
|
+
const filesPanel = (_jsxs(Panel, { title: "FILES [/] find", active: focus === "files", theme: theme, children: [visibleFileTree.map((entry, visibleIndex) => {
|
|
9
|
+
const selected = fileTreeStart + visibleIndex === fileIndex;
|
|
10
|
+
const marker = entry.kind === "directory" ? (entry.expanded ? "v " : "> ") : " ";
|
|
11
|
+
const indentation = " ".repeat(entry.depth * 2);
|
|
12
|
+
const width = Math.max(6, filesWidth - indentation.length - 7);
|
|
13
|
+
return (_jsxs(Text, { wrap: "truncate-end", color: selected
|
|
14
|
+
? theme.focus
|
|
15
|
+
: entry.kind === "directory"
|
|
16
|
+
? theme.directory
|
|
17
|
+
: entry.path === openFilePath
|
|
18
|
+
? theme.success
|
|
19
|
+
: theme.text, bold: selected || entry.kind === "directory", children: [selected ? "> " : " ", indentation, marker, truncate(entry.name, width)] }, `${entry.kind}:${entry.path}`));
|
|
20
|
+
}), !fileTreeLength && (_jsx(Text, { color: theme.muted, children: "No workspace files found." }))] }));
|
|
21
|
+
const editorPanel = (_jsx(Panel, { title: `${editorTitle} [up/down] scroll [d] diff${previewUrl ? " [o] browser" : ""}`, active: focus === "editor", theme: theme, children: visibleEditorRows.map((row) => {
|
|
22
|
+
const lineLabel = row.continuation
|
|
23
|
+
? " |"
|
|
24
|
+
: String(row.sourceLine).padStart(3);
|
|
25
|
+
return (_jsxs(Text, { wrap: "truncate-end", color: theme.text, children: [_jsx(Text, { color: theme.muted, children: lineLabel }), " ", (() => {
|
|
26
|
+
let offset = 0;
|
|
27
|
+
return row.tokens.map((token) => {
|
|
28
|
+
const key = `${offset}:${token.text}`;
|
|
29
|
+
offset += token.text.length;
|
|
30
|
+
return (_jsx(Text, { color: token.color ? theme.syntax[token.color] : theme.text, dimColor: token.dim, children: token.text }, key));
|
|
31
|
+
});
|
|
32
|
+
})()] }, row.key));
|
|
33
|
+
}) }));
|
|
34
|
+
const agentPanel = (_jsxs(Panel, { title: chatScroll
|
|
35
|
+
? `AGENT [${chatScroll} lines above]`
|
|
36
|
+
: "AGENT [Enter] send", active: focus === "chat", theme: theme, children: [activePlan && (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Text, { bold: true, color: theme.accent, 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"
|
|
37
|
+
? theme.success
|
|
38
|
+
: step.status === "in_progress"
|
|
39
|
+
? theme.warning
|
|
40
|
+
: theme.muted, children: [step.status === "completed"
|
|
41
|
+
? "[x]"
|
|
42
|
+
: step.status === "in_progress"
|
|
43
|
+
? "[..]"
|
|
44
|
+
: "[ ]", " ", truncate(step.content, Math.max(8, chatWidth - 7))] }, step.id)))] })), chatLines.map((line) => (_jsx(Text, { color: line.role === "user" ? theme.user : theme.agent, bold: line.header, children: line.text }, line.key))), _jsx(Box, { flexGrow: 1 }), _jsxs(Text, { wrap: "truncate-end", color: focus === "chat" ? theme.focus : theme.muted, children: [focus === "chat" ? "> " : " ", truncate(chatInput ||
|
|
45
|
+
(busy ? "Working... Escape cancels" : "Ask about this workspace"), Math.max(12, chatWidth - 5))] })] }));
|
|
46
|
+
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: theme.accent, children: brand.productName.toUpperCase() }), _jsx(Text, { color: theme.muted, children: configuration
|
|
47
|
+
? `${configuration.provider} / ${configuration.model}`
|
|
48
|
+
: "No model selected" }), _jsx(Text, { color: runStatus === "waiting"
|
|
49
|
+
? theme.warning
|
|
50
|
+
: busy
|
|
51
|
+
? theme.focus
|
|
52
|
+
: theme.success, children: runStatus === "waiting"
|
|
53
|
+
? "APPROVAL"
|
|
54
|
+
: runStatus === "tool"
|
|
55
|
+
? "TOOL"
|
|
56
|
+
: busy
|
|
57
|
+
? "WORKING"
|
|
58
|
+
: "READY" })] }), _jsxs(Box, { height: 1, justifyContent: "space-between", marginBottom: 1, children: [_jsxs(Text, { color: theme.muted, children: ["CONTEXT", " ", _jsxs(Text, { color: contextTokens / contextWindow >= 0.9
|
|
59
|
+
? theme.error
|
|
60
|
+
: contextTokens / contextWindow >= 0.7
|
|
61
|
+
? theme.warning
|
|
62
|
+
: theme.accent, children: [formatTokenCount(contextTokens), " /", " ", formatTokenCount(contextWindow)] }), " ", "estimated"] }), _jsxs(Text, { color: theme.muted, children: ["SPEED", " ", _jsx(Text, { color: busy ? theme.success : theme.muted, children: tokensPerSecond
|
|
63
|
+
? `${tokensPerSecond.toFixed(1)} tok/s`
|
|
64
|
+
: "-- tok/s" }), previewUrl
|
|
65
|
+
? ` PREVIEW ${truncate(previewUrl, Math.max(16, Math.floor(viewport.columns * 0.3)))}`
|
|
66
|
+
: ""] })] }), compact ? (_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", theme: theme, children: [visibleTerminalLines.map((line) => (_jsx(Text, { wrap: "truncate-end", color: line.text.startsWith("[agent error]") ||
|
|
67
|
+
line.text.startsWith("[terminal error]")
|
|
68
|
+
? theme.error
|
|
69
|
+
: line.text.startsWith("[tool")
|
|
70
|
+
? theme.warning
|
|
71
|
+
: theme.muted, children: truncate(line.text, Math.max(20, viewport.columns - 8)) }, line.id))), _jsx(Box, { flexGrow: 1 }), _jsxs(Text, { wrap: "truncate-end", color: focus === "terminal" ? theme.focus : theme.muted, 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: theme.muted, wrap: "truncate-end", children: "Tab focus | Ctrl+Left/Right pane | / find | a agents | c MCP | m settings | Esc cancel run | Ctrl+C exit idle" }), _jsx(Text, { color: theme.muted, children: sessionId ? `session ${sessionId.slice(0, 8)}` : "new session" })] }), _jsx(TuiOverlays, { theme: theme, ...overlays })] }));
|
|
72
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@truss-harness/tui",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.19",
|
|
4
4
|
"description": "Interactive terminal client for Truss.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -43,9 +43,9 @@
|
|
|
43
43
|
"ink": "^6.8.0",
|
|
44
44
|
"react": "^19.2.0",
|
|
45
45
|
"@truss-harness/branding": "0.1.1",
|
|
46
|
-
"@truss-harness/cli": "0.1.
|
|
47
|
-
"@truss-harness/provider-openai-compatible": "0.1.
|
|
48
|
-
"@truss-harness/runtime": "0.1.
|
|
46
|
+
"@truss-harness/cli": "^0.1.22",
|
|
47
|
+
"@truss-harness/provider-openai-compatible": "^0.1.14",
|
|
48
|
+
"@truss-harness/runtime": "^0.1.11"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
51
|
"@types/react": "^19.2.0"
|