@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.
@@ -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.14",
3
+ "version": "0.1.18",
4
4
  "description": "Interactive terminal client for Truss.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -43,8 +43,8 @@
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.18",
47
- "@truss-harness/provider-openai-compatible": "0.1.11",
46
+ "@truss-harness/cli": "0.1.21",
47
+ "@truss-harness/provider-openai-compatible": "0.1.13",
48
48
  "@truss-harness/runtime": "0.1.10"
49
49
  },
50
50
  "devDependencies": {