@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,44 @@
|
|
|
1
|
+
import type { LocalModelEndpoint } from "@truss-harness/provider-openai-compatible";
|
|
2
|
+
import type { AgentProfile, ToolCall } from "@truss-harness/runtime";
|
|
3
|
+
import type { FileEntry } from "./file-browser.js";
|
|
4
|
+
import type { TuiTheme, TuiThemeName } from "./theme.js";
|
|
5
|
+
import type { Screen, SettingsField } from "./types.js";
|
|
6
|
+
export declare function Panel({ title, active, theme, children, }: {
|
|
7
|
+
readonly title: string;
|
|
8
|
+
readonly active: boolean;
|
|
9
|
+
readonly theme: TuiTheme;
|
|
10
|
+
readonly children: React.ReactNode;
|
|
11
|
+
}): React.ReactElement;
|
|
12
|
+
interface McpToolView {
|
|
13
|
+
readonly name: string;
|
|
14
|
+
readonly readOnly: boolean;
|
|
15
|
+
}
|
|
16
|
+
interface McpServerView {
|
|
17
|
+
readonly name: string;
|
|
18
|
+
readonly state: string;
|
|
19
|
+
readonly error?: string;
|
|
20
|
+
readonly toolCount: number;
|
|
21
|
+
readonly tools?: readonly McpToolView[];
|
|
22
|
+
}
|
|
23
|
+
export interface TuiOverlaysProps {
|
|
24
|
+
readonly screen: Screen;
|
|
25
|
+
readonly theme: TuiTheme;
|
|
26
|
+
readonly overlayWidth: number;
|
|
27
|
+
readonly fileSearchInput: string;
|
|
28
|
+
readonly fileSearchResults: readonly FileEntry[];
|
|
29
|
+
readonly fileSearchIndex: number;
|
|
30
|
+
readonly settingsField: SettingsField;
|
|
31
|
+
readonly selectedEndpoint?: LocalModelEndpoint;
|
|
32
|
+
readonly endpointInput: string;
|
|
33
|
+
readonly modelInput: string;
|
|
34
|
+
readonly models: readonly string[];
|
|
35
|
+
readonly modelIndex: number;
|
|
36
|
+
readonly internetAccess: boolean;
|
|
37
|
+
readonly themeName: TuiThemeName;
|
|
38
|
+
readonly providerKind: string;
|
|
39
|
+
readonly pendingTool?: ToolCall;
|
|
40
|
+
readonly agentProfiles: readonly AgentProfile[];
|
|
41
|
+
readonly mcpStatuses: readonly McpServerView[];
|
|
42
|
+
}
|
|
43
|
+
export declare function TuiOverlays(props: TuiOverlaysProps): React.ReactElement;
|
|
44
|
+
export {};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { workspaceCommandHelp } from "@truss-harness/runtime";
|
|
3
|
+
import { Box, Text } from "ink";
|
|
4
|
+
import { truncate } from "./display.js";
|
|
5
|
+
export function Panel({ title, active, theme, children, }) {
|
|
6
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: active ? theme.focus : theme.panel, paddingX: 1, width: "100%", height: "100%", overflow: "hidden", children: [_jsx(Text, { color: active ? theme.focus : theme.muted, bold: true, children: title }), children] }));
|
|
7
|
+
}
|
|
8
|
+
const tuiControlHelp = [
|
|
9
|
+
[
|
|
10
|
+
"NAVIGATION",
|
|
11
|
+
"Tab / Shift+Tab move focus; Ctrl+Left / Ctrl+Right move to an adjacent pane.",
|
|
12
|
+
],
|
|
13
|
+
[
|
|
14
|
+
"FILES",
|
|
15
|
+
"/ opens fuzzy file search; Up/Down select; Left/Right collapse or expand; Enter opens.",
|
|
16
|
+
],
|
|
17
|
+
[
|
|
18
|
+
"EDITOR",
|
|
19
|
+
"Up/Down scroll; d toggles Git diff; o opens the detected preview URL externally.",
|
|
20
|
+
],
|
|
21
|
+
[
|
|
22
|
+
"AGENT",
|
|
23
|
+
"Enter sends; a opens saved multi-agent profiles; Up/Down scroll chat; n starts a new conversation outside the chat input.",
|
|
24
|
+
],
|
|
25
|
+
[
|
|
26
|
+
"MCP",
|
|
27
|
+
"c opens credential-safe server and tool status; R tests configured servers.",
|
|
28
|
+
],
|
|
29
|
+
[
|
|
30
|
+
"TERMINAL",
|
|
31
|
+
"Type directly and press Enter; Up/Down scroll output; slash commands run through Truss.",
|
|
32
|
+
],
|
|
33
|
+
[
|
|
34
|
+
"SETTINGS",
|
|
35
|
+
"m opens settings outside chat; Tab changes fields; Theme previews forest, sage, and dusk.",
|
|
36
|
+
],
|
|
37
|
+
["APPROVALS", "Y or Enter allows; N or Escape denies."],
|
|
38
|
+
[
|
|
39
|
+
"CANCEL / EXIT",
|
|
40
|
+
"Escape cancels an active agent run. Ctrl+C stops a process or run, and exits while idle.",
|
|
41
|
+
],
|
|
42
|
+
];
|
|
43
|
+
export function TuiOverlays(props) {
|
|
44
|
+
const { screen, theme, overlayWidth, fileSearchInput, fileSearchResults, fileSearchIndex, settingsField, selectedEndpoint, endpointInput, modelInput, models, modelIndex, internetAccess, themeName, providerKind, pendingTool, agentProfiles, mcpStatuses, } = props;
|
|
45
|
+
return (_jsxs(_Fragment, { children: [screen === "file-search" && (_jsxs(Box, { position: "absolute", flexDirection: "column", borderStyle: "double", borderColor: theme.focus, paddingX: 2, paddingY: 1, width: overlayWidth, marginLeft: 2, marginTop: 3, backgroundColor: theme.overlay, children: [_jsx(Text, { bold: true, color: theme.accent, children: "FIND FILE" }), _jsxs(Text, { color: theme.text, children: [_jsx(Text, { color: theme.focus, children: "> " }), fileSearchInput || (_jsx(Text, { color: theme.muted, children: "Type part of a file name or path" }))] }), _jsx(Text, { color: theme.muted, children: "Up/Down select Enter open Escape close" }), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [fileSearchResults.map((entry, index) => {
|
|
46
|
+
const selected = index ===
|
|
47
|
+
Math.min(fileSearchIndex, Math.max(0, fileSearchResults.length - 1));
|
|
48
|
+
const fileName = entry.path.split("/").at(-1) ?? entry.path;
|
|
49
|
+
return (_jsxs(Text, { color: selected ? theme.focus : theme.text, bold: selected, children: [selected ? "> " : " ", fileName, _jsxs(Text, { color: theme.muted, children: [" ", truncate(entry.path, Math.max(12, overlayWidth - fileName.length - 10))] })] }, entry.path));
|
|
50
|
+
}), !fileSearchResults.length && (_jsx(Text, { color: theme.warning, children: "No matching files." }))] })] })), screen === "settings" && (_jsxs(Box, { position: "absolute", flexDirection: "column", borderStyle: "double", borderColor: theme.focus, paddingX: 2, paddingY: 1, width: overlayWidth, marginLeft: 2, marginTop: 3, backgroundColor: theme.overlay, children: [_jsx(Text, { bold: true, color: theme.accent, children: "LOCAL MODEL CONFIGURATION" }), _jsx(Text, { color: theme.muted, children: "Tab changes fields. Enter selects an item or saves when endpoint and model are set." }), _jsxs(Text, { color: settingsField === "server" ? theme.focus : theme.text, children: ["SERVER: ", selectedEndpoint?.label ?? "Scanning local servers..."] }), _jsxs(Text, { color: settingsField === "endpoint" ? theme.focus : theme.text, children: ["ENDPOINT: ", endpointInput] }), _jsxs(Text, { color: settingsField === "model" ? theme.focus : theme.text, children: ["MODEL:", " ", modelInput || models[modelIndex] || "Choose or type a model"] }), _jsxs(Text, { color: settingsField === "internet" ? theme.focus : theme.text, children: ["INTERNET RESEARCH: ", internetAccess ? "enabled" : "disabled", " ", settingsField === "internet" ? "[Space toggles]" : ""] }), _jsxs(Text, { color: settingsField === "theme" ? theme.focus : theme.text, children: ["THEME: ", themeName, " ", settingsField === "theme" ? "[Left/Right changes]" : ""] }), _jsxs(Text, { color: theme.muted, children: ["Detected models:", " ", models.slice(0, 5).join(", ") || "none; type one manually"] }), _jsxs(Text, { color: theme.warning, children: ["Provider: ", providerKind, ". Local endpoints use the server selector. Configure cloud BYOK profiles with truss-cli setup, then relaunch the TUI."] })] })), screen === "approval" && (_jsxs(Box, { position: "absolute", flexDirection: "column", borderStyle: "double", borderColor: theme.warning, paddingX: 2, paddingY: 1, width: overlayWidth, marginLeft: 2, marginTop: 8, backgroundColor: theme.overlay, children: [_jsx(Text, { bold: true, color: theme.warning, children: "TOOL APPROVAL REQUIRED" }), _jsxs(Text, { color: theme.text, children: [pendingTool?.name, " ", JSON.stringify(pendingTool?.input)] }), _jsx(Text, { color: theme.muted, children: "Press Y or Enter to allow. Press N or Escape to deny." })] })), screen === "agents" && (_jsxs(Box, { position: "absolute", flexDirection: "column", borderStyle: "double", borderColor: theme.focus, paddingX: 2, paddingY: 1, width: overlayWidth, marginLeft: 2, marginTop: 6, backgroundColor: theme.overlay, children: [_jsx(Text, { bold: true, color: theme.accent, children: "MULTI-AGENT PROFILES" }), _jsx(Text, { color: theme.muted, children: "Profiles are shared with truss-cli in .truss-harness/agents.json. Use `truss agents run <id> <task>` for independent concurrent runs." }), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [agentProfiles.map((profile) => (_jsxs(Text, { color: theme.text, children: [_jsx(Text, { color: theme.focus, children: profile.displayName }), _jsxs(Text, { color: theme.muted, children: [" ", profile.id.slice(0, 8), " ", profile.provider.providerId, "/", profile.provider.modelId, " ", profile.mode, " ", profile.approvalPolicy] })] }, profile.id))), !agentProfiles.length && (_jsx(Text, { color: theme.warning, children: "No profiles yet. Add one with `truss agents add <name>`." }))] }), _jsx(Text, { color: theme.muted, children: "R refreshes. A or Escape closes." })] })), screen === "mcp" && (_jsxs(Box, { position: "absolute", flexDirection: "column", borderStyle: "double", borderColor: theme.focus, paddingX: 2, paddingY: 1, width: overlayWidth, marginLeft: 2, marginTop: 6, backgroundColor: theme.overlay, children: [_jsx(Text, { bold: true, color: theme.accent, children: "MCP CONNECTIONS" }), _jsx(Text, { color: theme.muted, children: "Status and discovered tools only. Commands, environment values, and credentials remain hidden." }), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [mcpStatuses.map((server) => (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: server.state === "failed"
|
|
51
|
+
? theme.error
|
|
52
|
+
: server.state === "connected"
|
|
53
|
+
? theme.success
|
|
54
|
+
: theme.warning, children: [_jsx(Text, { bold: true, children: server.name }), " ", server.state, server.error
|
|
55
|
+
? ` — ${server.error}`
|
|
56
|
+
: ` — ${server.toolCount} tool${server.toolCount === 1 ? "" : "s"}`] }), (server.tools ?? []).map((tool) => (_jsxs(Text, { color: theme.muted, children: [" ", tool.name, " [", tool.readOnly ? "read-only" : "approval", "]"] }, `${server.name}:${tool.name}`)))] }, server.name))), !mcpStatuses.length && (_jsx(Text, { color: theme.warning, children: "No MCP servers configured." }))] }), _jsx(Text, { color: theme.muted, children: "R tests configured servers. C or Escape closes." })] })), screen === "help" && (_jsxs(Box, { position: "absolute", flexDirection: "column", borderStyle: "double", borderColor: theme.focus, paddingX: 2, paddingY: 1, width: overlayWidth, marginLeft: 2, marginTop: 7, backgroundColor: theme.overlay, children: [_jsx(Text, { bold: true, color: theme.accent, children: "TUI CONTROLS" }), tuiControlHelp.map(([label, detail]) => (_jsxs(Text, { color: theme.text, wrap: "wrap", children: [_jsxs(Text, { bold: true, color: theme.focus, children: [label, ":", " "] }), detail] }, label))), _jsx(Box, { marginTop: 1, children: _jsx(Text, { bold: true, color: theme.accent, children: "WORKSPACE COMMANDS" }) }), workspaceCommandHelp()
|
|
57
|
+
.split("\n")
|
|
58
|
+
.slice(1)
|
|
59
|
+
.map((line) => (_jsx(Text, { color: theme.text, children: line }, line))), _jsx(Text, { color: theme.muted, children: "Press any key to close this reference." })] }))] }));
|
|
60
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ChatDisplayLine, ChatMessage } from "./types.js";
|
|
2
|
+
export declare function truncate(value: string, length: number): string;
|
|
3
|
+
export declare function clamp(value: number, minimum: number, maximum: number): number;
|
|
4
|
+
export declare function visibleLines<T>(items: readonly T[], count: number, offset: number): readonly T[];
|
|
5
|
+
export declare function estimateTokens(value: string): number;
|
|
6
|
+
export declare function formatTokenCount(value: number): string;
|
|
7
|
+
export declare function wrapText(value: string, width: number): string[];
|
|
8
|
+
export declare function plainChatText(value: string): string;
|
|
9
|
+
export declare function chatDisplayLines(messages: readonly ChatMessage[], busy: boolean, width: number): readonly ChatDisplayLine[];
|
|
10
|
+
export declare function configuredContextWindow(value?: string | undefined): number;
|
package/dist/display.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
export function truncate(value, length) {
|
|
2
|
+
return value.length <= length
|
|
3
|
+
? value
|
|
4
|
+
: `${value.slice(0, Math.max(0, length - 3))}...`;
|
|
5
|
+
}
|
|
6
|
+
export function clamp(value, minimum, maximum) {
|
|
7
|
+
return Math.min(Math.max(value, minimum), Math.max(minimum, maximum));
|
|
8
|
+
}
|
|
9
|
+
export function visibleLines(items, count, offset) {
|
|
10
|
+
const end = Math.max(0, items.length - clamp(offset, 0, Math.max(0, items.length - count)));
|
|
11
|
+
return items.slice(Math.max(0, end - count), end);
|
|
12
|
+
}
|
|
13
|
+
export function estimateTokens(value) {
|
|
14
|
+
const trimmed = value.trim();
|
|
15
|
+
return trimmed ? Math.ceil(trimmed.length / 4) : 0;
|
|
16
|
+
}
|
|
17
|
+
export function formatTokenCount(value) {
|
|
18
|
+
if (value < 1_000)
|
|
19
|
+
return String(Math.round(value));
|
|
20
|
+
return `${(value / 1_000).toFixed(value >= 10_000 ? 0 : 1).replace(/\.0$/, "")}k`;
|
|
21
|
+
}
|
|
22
|
+
export function wrapText(value, width) {
|
|
23
|
+
const result = [];
|
|
24
|
+
for (const sourceLine of value.split(/\r?\n/)) {
|
|
25
|
+
let remaining = sourceLine || " ";
|
|
26
|
+
while (remaining.length > width) {
|
|
27
|
+
const breakAt = Math.max(1, remaining.lastIndexOf(" ", width));
|
|
28
|
+
result.push(remaining.slice(0, breakAt).trimEnd());
|
|
29
|
+
remaining = remaining.slice(breakAt).trimStart();
|
|
30
|
+
}
|
|
31
|
+
result.push(remaining);
|
|
32
|
+
}
|
|
33
|
+
return result;
|
|
34
|
+
}
|
|
35
|
+
export function plainChatText(value) {
|
|
36
|
+
let inCodeBlock = false;
|
|
37
|
+
return value
|
|
38
|
+
.split(/\r?\n/)
|
|
39
|
+
.flatMap((sourceLine) => {
|
|
40
|
+
if (/^\s*```/.test(sourceLine)) {
|
|
41
|
+
inCodeBlock = !inCodeBlock;
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
if (inCodeBlock)
|
|
45
|
+
return [` ${sourceLine}`];
|
|
46
|
+
const line = sourceLine
|
|
47
|
+
.replace(/^\s{0,3}#{1,6}\s+/, "")
|
|
48
|
+
.replace(/^\s*>\s?/, "")
|
|
49
|
+
.replace(/^\s*[-*+]\s+/, "- ")
|
|
50
|
+
.replace(/^\s*\d+[.)]\s+/, "- ")
|
|
51
|
+
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1")
|
|
52
|
+
.replace(/`([^`]+)`/g, "$1")
|
|
53
|
+
.replace(/(\*\*|__|\*|_|~~)/g, "");
|
|
54
|
+
return /^\s*[-:|]+\s*$/.test(line) ? [] : [line];
|
|
55
|
+
})
|
|
56
|
+
.join("\n");
|
|
57
|
+
}
|
|
58
|
+
export function chatDisplayLines(messages, busy, width) {
|
|
59
|
+
return messages.flatMap((message, messageIndex) => {
|
|
60
|
+
const content = message.content
|
|
61
|
+
? message.role === "assistant"
|
|
62
|
+
? plainChatText(message.content)
|
|
63
|
+
: message.content
|
|
64
|
+
: busy && message.role === "assistant"
|
|
65
|
+
? "Thinking..."
|
|
66
|
+
: "";
|
|
67
|
+
return [
|
|
68
|
+
{
|
|
69
|
+
key: `${messageIndex}:header`,
|
|
70
|
+
role: message.role,
|
|
71
|
+
text: message.role === "user" ? "YOU" : "AGENT",
|
|
72
|
+
header: true,
|
|
73
|
+
},
|
|
74
|
+
...wrapText(content, width).map((text, lineIndex) => ({
|
|
75
|
+
key: `${messageIndex}:${lineIndex}`,
|
|
76
|
+
role: message.role,
|
|
77
|
+
text,
|
|
78
|
+
header: false,
|
|
79
|
+
})),
|
|
80
|
+
];
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
export function configuredContextWindow(value = process.env.TRUSS_HARNESS_CONTEXT_WINDOW) {
|
|
84
|
+
const configured = Number.parseInt(value ?? "8192", 10);
|
|
85
|
+
return Number.isFinite(configured)
|
|
86
|
+
? Math.max(512, Math.min(1_000_000, configured))
|
|
87
|
+
: 8_192;
|
|
88
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { LocalModelEndpoint, ModelProviderKind } from "@truss-harness/provider-openai-compatible";
|
|
2
|
+
import type { Dispatch, SetStateAction } from "react";
|
|
3
|
+
import type { FileEntry, FileTreeEntry } from "./file-browser.js";
|
|
4
|
+
import { type TuiThemeName } from "./theme.js";
|
|
5
|
+
import type { EditorDisplayRow, Focus, Screen, SettingsField } from "./types.js";
|
|
6
|
+
type Setter<T> = Dispatch<SetStateAction<T>>;
|
|
7
|
+
interface InputState {
|
|
8
|
+
readonly screen: Screen;
|
|
9
|
+
readonly focus: Focus;
|
|
10
|
+
readonly busy: boolean;
|
|
11
|
+
readonly fileSearchResults: readonly FileEntry[];
|
|
12
|
+
readonly fileSearchIndex: number;
|
|
13
|
+
readonly candidates: readonly LocalModelEndpoint[];
|
|
14
|
+
readonly selectedEndpoint?: LocalModelEndpoint;
|
|
15
|
+
readonly settingsField: SettingsField;
|
|
16
|
+
readonly endpointInput: string;
|
|
17
|
+
readonly modelInput: string;
|
|
18
|
+
readonly models: readonly string[];
|
|
19
|
+
readonly modelIndex: number;
|
|
20
|
+
readonly configurationAvailable: boolean;
|
|
21
|
+
readonly previewUrl?: string;
|
|
22
|
+
readonly terminalLineCount: number;
|
|
23
|
+
readonly terminalHeight: number;
|
|
24
|
+
readonly commandInput: string;
|
|
25
|
+
readonly fileTree: readonly FileTreeEntry[];
|
|
26
|
+
readonly selectedFileTreeEntry?: FileTreeEntry;
|
|
27
|
+
readonly editorRows: readonly EditorDisplayRow[];
|
|
28
|
+
readonly editorLineCount: number;
|
|
29
|
+
readonly chatTranscriptLength: number;
|
|
30
|
+
readonly chatLineCount: number;
|
|
31
|
+
}
|
|
32
|
+
interface InputSetters {
|
|
33
|
+
readonly setScreen: Setter<Screen>;
|
|
34
|
+
readonly setFileSearchInput: Setter<string>;
|
|
35
|
+
readonly setFileSearchIndex: Setter<number>;
|
|
36
|
+
readonly setSettingsField: Setter<SettingsField>;
|
|
37
|
+
readonly setServerIndex: Setter<number>;
|
|
38
|
+
readonly setProviderKind: Setter<ModelProviderKind>;
|
|
39
|
+
readonly setEndpointInput: Setter<string>;
|
|
40
|
+
readonly setModelIndex: Setter<number>;
|
|
41
|
+
readonly setModelInput: Setter<string>;
|
|
42
|
+
readonly setInternetAccess: Setter<boolean>;
|
|
43
|
+
readonly setThemeName: Setter<TuiThemeName>;
|
|
44
|
+
readonly setTerminalScroll: Setter<number>;
|
|
45
|
+
readonly setCommandInput: Setter<string>;
|
|
46
|
+
readonly setFileIndex: Setter<number>;
|
|
47
|
+
readonly setEditorScroll: Setter<number>;
|
|
48
|
+
readonly setChatScroll: Setter<number>;
|
|
49
|
+
readonly setChatInput: Setter<string>;
|
|
50
|
+
}
|
|
51
|
+
interface InputActions {
|
|
52
|
+
readonly interruptOrExit: () => void;
|
|
53
|
+
readonly cancelRun: () => void;
|
|
54
|
+
readonly resolveApproval: (approved: boolean) => void;
|
|
55
|
+
readonly openSearchResult: (entry: FileEntry) => void;
|
|
56
|
+
readonly refreshAgentProfiles: () => void;
|
|
57
|
+
readonly testMcpConnections: () => void;
|
|
58
|
+
readonly configureRuntime: () => void;
|
|
59
|
+
readonly moveFocus: (direction: 1 | -1) => void;
|
|
60
|
+
readonly openPreview: (url: string) => void;
|
|
61
|
+
readonly runTerminalInput: (input: string) => void;
|
|
62
|
+
readonly startNewConversation: () => void;
|
|
63
|
+
readonly toggleDirectory: (path: string, expand?: boolean) => void;
|
|
64
|
+
readonly loadFile: (entry: FileEntry) => void;
|
|
65
|
+
readonly toggleDiff: () => void;
|
|
66
|
+
readonly sendPrompt: () => void;
|
|
67
|
+
}
|
|
68
|
+
export interface TuiInputControllerOptions {
|
|
69
|
+
readonly state: InputState;
|
|
70
|
+
readonly setters: InputSetters;
|
|
71
|
+
readonly actions: InputActions;
|
|
72
|
+
}
|
|
73
|
+
export declare function useTuiInputController({ state, setters, actions, }: TuiInputControllerOptions): void;
|
|
74
|
+
export {};
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { useInput } from "ink";
|
|
2
|
+
import { clamp } from "./display.js";
|
|
3
|
+
import { tuiThemeNames } from "./theme.js";
|
|
4
|
+
export function useTuiInputController({ state, setters, actions, }) {
|
|
5
|
+
useInput((input, key) => {
|
|
6
|
+
if (key.ctrl && input === "c") {
|
|
7
|
+
actions.interruptOrExit();
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
if (state.screen === "approval") {
|
|
11
|
+
if (input.toLowerCase() === "y" || key.return)
|
|
12
|
+
actions.resolveApproval(true);
|
|
13
|
+
if (input.toLowerCase() === "n" || key.escape)
|
|
14
|
+
actions.resolveApproval(false);
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
if (state.screen === "file-search") {
|
|
18
|
+
if (key.escape) {
|
|
19
|
+
setters.setFileSearchInput("");
|
|
20
|
+
setters.setFileSearchIndex(0);
|
|
21
|
+
setters.setScreen("workspace");
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
if (key.upArrow) {
|
|
25
|
+
setters.setFileSearchIndex((current) => Math.max(0, current - 1));
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (key.downArrow) {
|
|
29
|
+
setters.setFileSearchIndex((current) => Math.min(Math.max(0, state.fileSearchResults.length - 1), current + 1));
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (key.return) {
|
|
33
|
+
const result = state.fileSearchResults[Math.min(state.fileSearchIndex, Math.max(0, state.fileSearchResults.length - 1))];
|
|
34
|
+
if (result)
|
|
35
|
+
actions.openSearchResult(result);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (key.backspace || key.delete) {
|
|
39
|
+
setters.setFileSearchInput((current) => current.slice(0, -1));
|
|
40
|
+
setters.setFileSearchIndex(0);
|
|
41
|
+
}
|
|
42
|
+
else if (input) {
|
|
43
|
+
setters.setFileSearchInput((current) => current + input);
|
|
44
|
+
setters.setFileSearchIndex(0);
|
|
45
|
+
}
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (state.screen === "help") {
|
|
49
|
+
setters.setScreen("workspace");
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (state.screen === "agents") {
|
|
53
|
+
if (key.escape || input.toLowerCase() === "a")
|
|
54
|
+
setters.setScreen("workspace");
|
|
55
|
+
if (input.toLowerCase() === "r")
|
|
56
|
+
actions.refreshAgentProfiles();
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (state.screen === "mcp") {
|
|
60
|
+
if (key.escape || input.toLowerCase() === "c") {
|
|
61
|
+
setters.setScreen("workspace");
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (input.toLowerCase() === "r" && state.configurationAvailable)
|
|
65
|
+
actions.testMcpConnections();
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (state.screen === "settings") {
|
|
69
|
+
if (key.escape) {
|
|
70
|
+
setters.setScreen("workspace");
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (key.tab) {
|
|
74
|
+
const fields = [
|
|
75
|
+
"server",
|
|
76
|
+
"endpoint",
|
|
77
|
+
"model",
|
|
78
|
+
"internet",
|
|
79
|
+
"theme",
|
|
80
|
+
];
|
|
81
|
+
setters.setSettingsField((current) => {
|
|
82
|
+
const index = fields.indexOf(current);
|
|
83
|
+
return fields[(index + (key.shift ? -1 : 1) + fields.length) % fields.length];
|
|
84
|
+
});
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (state.settingsField === "server") {
|
|
88
|
+
if (key.upArrow)
|
|
89
|
+
setters.setServerIndex((current) => Math.max(0, current - 1));
|
|
90
|
+
if (key.downArrow)
|
|
91
|
+
setters.setServerIndex((current) => Math.min(state.candidates.length - 1, current + 1));
|
|
92
|
+
if (key.return && state.selectedEndpoint) {
|
|
93
|
+
setters.setProviderKind(state.selectedEndpoint.kind);
|
|
94
|
+
setters.setEndpointInput(state.selectedEndpoint.baseUrl);
|
|
95
|
+
setters.setSettingsField("model");
|
|
96
|
+
}
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (state.settingsField === "model") {
|
|
100
|
+
if (key.upArrow)
|
|
101
|
+
setters.setModelIndex((current) => Math.max(0, current - 1));
|
|
102
|
+
if (key.downArrow)
|
|
103
|
+
setters.setModelIndex((current) => Math.min(state.models.length - 1, current + 1));
|
|
104
|
+
if (state.models[state.modelIndex] && (key.return || key.rightArrow))
|
|
105
|
+
setters.setModelInput(state.models[state.modelIndex]);
|
|
106
|
+
}
|
|
107
|
+
if (state.settingsField === "internet") {
|
|
108
|
+
if (input === " " || key.leftArrow || key.rightArrow)
|
|
109
|
+
setters.setInternetAccess((current) => !current);
|
|
110
|
+
if (key.return && state.endpointInput && state.modelInput)
|
|
111
|
+
actions.configureRuntime();
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (state.settingsField === "theme") {
|
|
115
|
+
if (key.leftArrow || key.upArrow)
|
|
116
|
+
setters.setThemeName((current) => tuiThemeNames[(tuiThemeNames.indexOf(current) - 1 + tuiThemeNames.length) %
|
|
117
|
+
tuiThemeNames.length]);
|
|
118
|
+
if (key.rightArrow || key.downArrow || input === " ")
|
|
119
|
+
setters.setThemeName((current) => tuiThemeNames[(tuiThemeNames.indexOf(current) + 1) % tuiThemeNames.length]);
|
|
120
|
+
if (key.return && state.endpointInput && state.modelInput)
|
|
121
|
+
actions.configureRuntime();
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (key.return && state.endpointInput && state.modelInput) {
|
|
125
|
+
actions.configureRuntime();
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const setter = state.settingsField === "endpoint"
|
|
129
|
+
? setters.setEndpointInput
|
|
130
|
+
: setters.setModelInput;
|
|
131
|
+
if (key.backspace || key.delete)
|
|
132
|
+
setter((current) => current.slice(0, -1));
|
|
133
|
+
else if (!key.return && input)
|
|
134
|
+
setter((current) => current + input);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (key.ctrl && (key.leftArrow || key.rightArrow)) {
|
|
138
|
+
actions.moveFocus(key.leftArrow ? -1 : 1);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (input.toLowerCase() === "a" &&
|
|
142
|
+
state.focus !== "chat" &&
|
|
143
|
+
state.focus !== "terminal") {
|
|
144
|
+
setters.setScreen("agents");
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (key.tab) {
|
|
148
|
+
actions.moveFocus(key.shift ? -1 : 1);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (key.escape && state.busy) {
|
|
152
|
+
actions.cancelRun();
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (input === "o" &&
|
|
156
|
+
state.focus !== "chat" &&
|
|
157
|
+
state.focus !== "terminal" &&
|
|
158
|
+
state.previewUrl) {
|
|
159
|
+
actions.openPreview(state.previewUrl);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (state.focus === "terminal") {
|
|
163
|
+
if (key.upArrow) {
|
|
164
|
+
setters.setTerminalScroll((current) => clamp(current + 1, 0, Math.max(0, state.terminalLineCount - Math.max(1, state.terminalHeight - 4))));
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (key.downArrow) {
|
|
168
|
+
setters.setTerminalScroll((current) => clamp(current - 1, 0, Math.max(0, state.terminalLineCount - Math.max(1, state.terminalHeight - 4))));
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (key.return) {
|
|
172
|
+
const command = state.commandInput.trim();
|
|
173
|
+
setters.setCommandInput("");
|
|
174
|
+
if (command)
|
|
175
|
+
actions.runTerminalInput(command);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (key.backspace || key.delete)
|
|
179
|
+
setters.setCommandInput((current) => current.slice(0, -1));
|
|
180
|
+
else if (input)
|
|
181
|
+
setters.setCommandInput((current) => current + input);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (input === "?" && state.focus !== "chat") {
|
|
185
|
+
setters.setScreen("help");
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (input === "m" && state.focus !== "chat") {
|
|
189
|
+
setters.setScreen("settings");
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (input.toLowerCase() === "c" && state.focus !== "chat") {
|
|
193
|
+
setters.setScreen("mcp");
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
if (input === "n" && state.focus !== "chat") {
|
|
197
|
+
actions.startNewConversation();
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (state.focus === "files") {
|
|
201
|
+
if (input === "/") {
|
|
202
|
+
setters.setFileSearchInput("");
|
|
203
|
+
setters.setFileSearchIndex(0);
|
|
204
|
+
setters.setScreen("file-search");
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (key.upArrow)
|
|
208
|
+
setters.setFileIndex((current) => Math.max(0, current - 1));
|
|
209
|
+
if (key.downArrow)
|
|
210
|
+
setters.setFileIndex((current) => Math.min(Math.max(state.fileTree.length - 1, 0), current + 1));
|
|
211
|
+
if (key.leftArrow && state.selectedFileTreeEntry?.kind === "directory")
|
|
212
|
+
actions.toggleDirectory(state.selectedFileTreeEntry.path, false);
|
|
213
|
+
if (key.rightArrow && state.selectedFileTreeEntry?.kind === "directory")
|
|
214
|
+
actions.toggleDirectory(state.selectedFileTreeEntry.path, true);
|
|
215
|
+
if (key.return && state.selectedFileTreeEntry?.kind === "directory")
|
|
216
|
+
actions.toggleDirectory(state.selectedFileTreeEntry.path);
|
|
217
|
+
if (key.return && state.selectedFileTreeEntry?.kind === "file")
|
|
218
|
+
actions.loadFile(state.selectedFileTreeEntry);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (state.focus === "editor") {
|
|
222
|
+
if (key.upArrow) {
|
|
223
|
+
setters.setEditorScroll((current) => clamp(current - 1, 0, Math.max(0, state.editorRows.length - state.editorLineCount)));
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
if (key.downArrow) {
|
|
227
|
+
setters.setEditorScroll((current) => clamp(current + 1, 0, Math.max(0, state.editorRows.length - state.editorLineCount)));
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if (input === "d")
|
|
231
|
+
actions.toggleDiff();
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (key.upArrow) {
|
|
235
|
+
setters.setChatScroll((current) => clamp(current + 1, 0, Math.max(0, state.chatTranscriptLength - state.chatLineCount)));
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
if (key.downArrow) {
|
|
239
|
+
setters.setChatScroll((current) => clamp(current - 1, 0, Math.max(0, state.chatTranscriptLength - state.chatLineCount)));
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
if (key.return) {
|
|
243
|
+
actions.sendPrompt();
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
if (key.backspace || key.delete)
|
|
247
|
+
setters.setChatInput((current) => current.slice(0, -1));
|
|
248
|
+
else if (input)
|
|
249
|
+
setters.setChatInput((current) => current + input);
|
|
250
|
+
});
|
|
251
|
+
}
|
package/dist/layout.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type Focus, type Viewport } from "./types.js";
|
|
2
|
+
export interface TuiLayout {
|
|
3
|
+
readonly compact: boolean;
|
|
4
|
+
readonly terminalHeight: number;
|
|
5
|
+
readonly workspaceHeight: number;
|
|
6
|
+
readonly filesWidth: number;
|
|
7
|
+
readonly chatWidth: number;
|
|
8
|
+
readonly editorWidth: number;
|
|
9
|
+
readonly compactChatHeight: number;
|
|
10
|
+
readonly editorHeight: number;
|
|
11
|
+
readonly editorLineCount: number;
|
|
12
|
+
readonly overlayWidth: number;
|
|
13
|
+
}
|
|
14
|
+
export declare function calculateLayout(viewport: Viewport): TuiLayout;
|
|
15
|
+
export type FocusAction = {
|
|
16
|
+
readonly type: "next";
|
|
17
|
+
} | {
|
|
18
|
+
readonly type: "previous";
|
|
19
|
+
} | {
|
|
20
|
+
readonly type: "select";
|
|
21
|
+
readonly focus: Focus;
|
|
22
|
+
};
|
|
23
|
+
export declare function focusReducer(current: Focus, action: FocusAction): Focus;
|
package/dist/layout.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { clamp } from "./display.js";
|
|
2
|
+
import { focusOrder } from "./types.js";
|
|
3
|
+
export function calculateLayout(viewport) {
|
|
4
|
+
const compact = viewport.columns < 106;
|
|
5
|
+
const terminalHeight = clamp(Math.floor(viewport.rows * 0.24), 7, 11);
|
|
6
|
+
const workspaceHeight = Math.max(compact ? 14 : 9, viewport.rows - terminalHeight - 6);
|
|
7
|
+
const filesWidth = Math.max(20, Math.min(36, Math.floor(viewport.columns * 0.24)));
|
|
8
|
+
const chatWidth = compact
|
|
9
|
+
? Math.max(40, viewport.columns - 4)
|
|
10
|
+
: Math.max(28, Math.min(48, Math.floor(viewport.columns * 0.31)));
|
|
11
|
+
const editorWidth = compact
|
|
12
|
+
? Math.max(30, viewport.columns - filesWidth - 4)
|
|
13
|
+
: Math.max(30, viewport.columns - filesWidth - chatWidth - 4);
|
|
14
|
+
const compactChatHeight = compact
|
|
15
|
+
? clamp(Math.floor(workspaceHeight * 0.4), 6, 10)
|
|
16
|
+
: workspaceHeight;
|
|
17
|
+
const editorHeight = compact
|
|
18
|
+
? Math.max(6, workspaceHeight - compactChatHeight - 1)
|
|
19
|
+
: workspaceHeight;
|
|
20
|
+
return {
|
|
21
|
+
compact,
|
|
22
|
+
terminalHeight,
|
|
23
|
+
workspaceHeight,
|
|
24
|
+
filesWidth,
|
|
25
|
+
chatWidth,
|
|
26
|
+
editorWidth,
|
|
27
|
+
compactChatHeight,
|
|
28
|
+
editorHeight,
|
|
29
|
+
editorLineCount: Math.max(2, editorHeight - 3),
|
|
30
|
+
overlayWidth: clamp(viewport.columns - 4, 42, 90),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
export function focusReducer(current, action) {
|
|
34
|
+
if (action.type === "select")
|
|
35
|
+
return action.focus;
|
|
36
|
+
const direction = action.type === "next" ? 1 : -1;
|
|
37
|
+
const index = focusOrder.indexOf(current);
|
|
38
|
+
return focusOrder[(index + direction + focusOrder.length) % focusOrder.length];
|
|
39
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type ChildProcess } from "node:child_process";
|
|
2
|
+
export declare function runTrackedCommand(command: string, workspaceRoot: string, onOutput: (output: string) => void, onProcess?: (process: ChildProcess | undefined) => void): Promise<void>;
|
|
3
|
+
export declare function detectedPreviewUrl(output: string): string | undefined;
|
|
4
|
+
export declare function normalizedPreviewUrl(value: string): string;
|
|
5
|
+
export declare function openExternalPreview(value: string): void;
|
|
6
|
+
export declare function readWorkingTreeDiff(workspaceRoot: string, path: string): Promise<string>;
|
|
7
|
+
export declare function stopProcessTree(child: ChildProcess): Promise<void>;
|