@truss-harness/tui 0.1.1 → 0.1.3

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 CHANGED
@@ -13,8 +13,22 @@ truss-tui
13
13
 
14
14
  Run `truss-tui --help` for a non-interactive launch reference. Inside the TUI, press `?` for the complete keyboard-control reference.
15
15
 
16
+ On Windows, use PowerShell or Windows Terminal. When launching from Git Bash, Truss automatically uses Git for Windows' `winpty` bridge when available; `winpty truss-tui` is the manual fallback.
17
+
16
18
  The TUI reads the same `.truss-harness/config.json` profiles as `@truss-harness/cli`, or lets you select a detected Ollama, LM Studio, llama.cpp, or custom compatible endpoint at startup.
17
19
 
20
+ ## Themes
21
+
22
+ The default `forest` theme uses restrained green, sage, and teal accents with soft syntax colors. Choose `sage` for a lighter green palette or `dusk` for a quieter deep-green palette. Set a persistent preference in the shared configuration file or use `TRUSS_HARNESS_TUI_THEME`:
23
+
24
+ ```json
25
+ {
26
+ "tuiTheme": "forest"
27
+ }
28
+ ```
29
+
30
+ Inside the TUI, open settings with `m`, Tab to **Theme**, and use Left or Right to preview a preset for the current session.
31
+
18
32
  Configured MCP stdio servers are connected when an Agent session starts, and their connection status appears in the Terminal pane. Plan mode loads only servers explicitly marked `readOnly`. Run `truss-cli config path` to locate the shared user and workspace configuration files.
19
33
 
20
34
  ## Controls
package/dist/bin.js CHANGED
@@ -1,11 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { spawn } from "node:child_process";
3
+ import { spawn, spawnSync } from "node:child_process";
4
4
  import { execFile as execFileCallback } from "node:child_process";
5
5
  import { readdir, readFile } from "node:fs/promises";
6
6
  import { join, relative } from "node:path";
7
7
  import { cwd } from "node:process";
8
8
  import { promisify } from "node:util";
9
+ import { fileURLToPath } from "node:url";
9
10
  import { Box, render, Text, useApp, useInput } from "ink";
10
11
  import { useEffect, useMemo, useRef, useState } from "react";
11
12
  import { detectLocalEndpoints, listLocalModels } from "@truss-harness/provider-openai-compatible";
@@ -14,6 +15,7 @@ import { resolveConfiguration } from "@truss-harness/cli/config";
14
15
  import { createClientRuntime } from "@truss-harness/cli/runtime";
15
16
  import { executeWorkspaceCommand, FileWorkspacePlanStore, workspaceCommandHelp } from "@truss-harness/runtime";
16
17
  import { buildFileTree, fuzzyFiles, syntaxTokens, wrapSyntaxTokens } from "./file-browser.js";
18
+ import { tuiTheme, tuiThemeNames } from "./theme.js";
17
19
  const tuiHelp = `${brand.productName} TUI
18
20
 
19
21
  Full-screen terminal workspace for local coding models.
@@ -25,10 +27,37 @@ The TUI starts in the current workspace and reads the same configuration
25
27
  profiles as ${brand.cliCommand}. Press ? inside the TUI for its complete
26
28
  keyboard-control reference.
27
29
  `;
30
+ const tuiControlHelp = [
31
+ ["NAVIGATION", "Tab / Shift+Tab move focus; Ctrl+Left / Ctrl+Right move to an adjacent pane."],
32
+ ["FILES", "/ opens fuzzy file search; Up/Down select; Left/Right collapse or expand; Enter opens."],
33
+ ["EDITOR", "Up/Down scroll; d toggles Git diff; o opens the detected preview URL externally."],
34
+ ["AGENT", "Enter sends; Up/Down scroll chat; n starts a new conversation outside the chat input."],
35
+ ["TERMINAL", "Type directly and press Enter; Up/Down scroll output; slash commands run through Truss."],
36
+ ["SETTINGS", "m opens settings outside chat; Tab changes fields; Theme previews forest, sage, and dusk."],
37
+ ["APPROVALS", "Y or Enter allows; N or Escape denies."],
38
+ ["CANCEL / EXIT", "Escape cancels an active agent run. Ctrl+C stops a process or run, and exits while idle."]
39
+ ];
28
40
  if (process.argv.slice(2).some((argument) => argument === "--help" || argument === "-h")) {
29
41
  process.stdout.write(tuiHelp);
30
42
  process.exit(0);
31
43
  }
44
+ function ensureInteractiveTerminal() {
45
+ if (process.stdin.isTTY && typeof process.stdin.setRawMode === "function")
46
+ return;
47
+ // Git Bash exposes a pipe to Node for some console programs. winpty creates
48
+ // the Windows console bridge Ink needs for keyboard input.
49
+ if (process.platform === "win32" && process.env.MSYSTEM && process.env.TRUSS_TUI_WINPTY !== "1") {
50
+ const result = spawnSync("winpty", [process.execPath, fileURLToPath(import.meta.url), ...process.argv.slice(2)], {
51
+ stdio: "inherit",
52
+ env: { ...process.env, TRUSS_TUI_WINPTY: "1" }
53
+ });
54
+ if (!result.error)
55
+ process.exit(result.status ?? 1);
56
+ }
57
+ process.stderr.write(brand.productName + " TUI needs an interactive terminal with raw keyboard input. Open PowerShell or Windows Terminal and run " + brand.tuiCommand + ", or run winpty " + brand.tuiCommand + " from Git Bash.\n");
58
+ process.exit(1);
59
+ }
60
+ ensureInteractiveTerminal();
32
61
  const execFile = promisify(execFileCallback);
33
62
  const ignoredDirectories = new Set([".git", ".next", ".truss-harness", "node_modules", "dist", "coverage"]);
34
63
  const focusOrder = ["files", "editor", "chat", "terminal"];
@@ -64,9 +93,31 @@ function wrapText(value, width) {
64
93
  }
65
94
  return result;
66
95
  }
96
+ function plainChatText(value) {
97
+ let inCodeBlock = false;
98
+ return value.split(/\r?\n/).flatMap((sourceLine) => {
99
+ if (/^\s*```/.test(sourceLine)) {
100
+ inCodeBlock = !inCodeBlock;
101
+ return [];
102
+ }
103
+ if (inCodeBlock)
104
+ return [` ${sourceLine}`];
105
+ const line = sourceLine
106
+ .replace(/^\s{0,3}#{1,6}\s+/, "")
107
+ .replace(/^\s*>\s?/, "")
108
+ .replace(/^\s*[-*+]\s+/, "- ")
109
+ .replace(/^\s*\d+[.)]\s+/, "- ")
110
+ .replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1")
111
+ .replace(/`([^`]+)`/g, "$1")
112
+ .replace(/(\*\*|__|\*|_|~~)/g, "");
113
+ return /^\s*[-:|]+\s*$/.test(line) ? [] : [line];
114
+ }).join("\n");
115
+ }
67
116
  function chatDisplayLines(messages, busy, width) {
68
117
  const transcript = messages.flatMap((message) => {
69
- const content = message.content || (busy && message.role === "assistant" ? "Thinking..." : "");
118
+ const content = message.content
119
+ ? message.role === "assistant" ? plainChatText(message.content) : message.content
120
+ : busy && message.role === "assistant" ? "Thinking..." : "";
70
121
  return [
71
122
  { role: message.role, text: message.role === "user" ? "YOU" : "AGENT", header: true },
72
123
  ...wrapText(content, width).map((text) => ({ role: message.role, text, header: false }))
@@ -139,8 +190,8 @@ async function stopProcessTree(child) {
139
190
  }
140
191
  child.kill();
141
192
  }
142
- function Panel({ title, active, children }) {
143
- return _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: active ? "cyan" : "gray", paddingX: 1, width: "100%", height: "100%", overflow: "hidden", children: [_jsx(Text, { color: active ? "cyan" : "gray", bold: true, children: title }), children] });
193
+ function Panel({ title, active, theme, children }) {
194
+ 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] });
144
195
  }
145
196
  function App({ initialConfiguration }) {
146
197
  const { exit } = useApp();
@@ -178,6 +229,7 @@ function App({ initialConfiguration }) {
178
229
  const [models, setModels] = useState([]);
179
230
  const [modelIndex, setModelIndex] = useState(0);
180
231
  const [settingsField, setSettingsField] = useState("server");
232
+ const [themeName, setThemeName] = useState(initialConfiguration?.tuiTheme ?? "forest");
181
233
  const [endpointInput, setEndpointInput] = useState(initialConfiguration?.baseUrl ?? "http://127.0.0.1:11434");
182
234
  const [modelInput, setModelInput] = useState(initialConfiguration?.model ?? "");
183
235
  const [providerKind, setProviderKind] = useState(initialConfiguration?.provider ?? "ollama");
@@ -192,6 +244,7 @@ function App({ initialConfiguration }) {
192
244
  const tokensPerSecond = streamMetrics.startedAt && streamMetrics.generatedTokens
193
245
  ? streamMetrics.generatedTokens / Math.max((Date.now() - streamMetrics.startedAt) / 1_000, 0.1)
194
246
  : undefined;
247
+ const theme = tuiTheme(themeName);
195
248
  const appendTerminal = (output) => {
196
249
  const next = output.replace(/\r/g, "").split("\n").filter(Boolean);
197
250
  if (!next.length)
@@ -592,7 +645,7 @@ function App({ initialConfiguration }) {
592
645
  return;
593
646
  }
594
647
  if (key.tab) {
595
- const fields = ["server", "endpoint", "model", "internet"];
648
+ const fields = ["server", "endpoint", "model", "internet", "theme"];
596
649
  setSettingsField((current) => {
597
650
  const index = fields.indexOf(current);
598
651
  return fields[(index + (key.shift ? -1 : 1) + fields.length) % fields.length];
@@ -626,6 +679,15 @@ function App({ initialConfiguration }) {
626
679
  void configureRuntime();
627
680
  return;
628
681
  }
682
+ if (settingsField === "theme") {
683
+ if (key.leftArrow || key.upArrow)
684
+ setThemeName((current) => tuiThemeNames[(tuiThemeNames.indexOf(current) - 1 + tuiThemeNames.length) % tuiThemeNames.length]);
685
+ if (key.rightArrow || key.downArrow || input === " ")
686
+ setThemeName((current) => tuiThemeNames[(tuiThemeNames.indexOf(current) + 1) % tuiThemeNames.length]);
687
+ if (key.return && endpointInput && modelInput)
688
+ void configureRuntime();
689
+ return;
690
+ }
629
691
  if (key.return && endpointInput && modelInput) {
630
692
  void configureRuntime();
631
693
  return;
@@ -746,23 +808,23 @@ function App({ initialConfiguration }) {
746
808
  else if (input)
747
809
  setChatInput((current) => current + input);
748
810
  });
749
- const filesPanel = _jsxs(Panel, { title: "FILES [/] find", active: focus === "files", children: [visibleFileTree.map((entry, visibleIndex) => {
811
+ const filesPanel = _jsxs(Panel, { title: "FILES [/] find", active: focus === "files", theme: theme, children: [visibleFileTree.map((entry, visibleIndex) => {
750
812
  const selected = fileTreeStart + visibleIndex === fileIndex;
751
813
  const marker = entry.kind === "directory" ? (entry.expanded ? "v " : "> ") : " ";
752
814
  const indentation = " ".repeat(entry.depth * 2);
753
815
  const width = Math.max(6, filesWidth - indentation.length - 7);
754
- return _jsxs(Text, { wrap: "truncate-end", color: selected ? "cyan" : entry.kind === "directory" ? "blue" : entry.path === openFilePath ? "green" : undefined, bold: selected || entry.kind === "directory", children: [selected ? "> " : " ", indentation, marker, truncate(entry.name, width)] }, `${entry.kind}:${entry.path}`);
755
- }), !fileTree.length && _jsx(Text, { color: "gray", children: "No workspace files found." })] });
756
- const editorPanel = _jsx(Panel, { title: `${editorTitle} [up/down] scroll [d] diff${previewUrl ? " [o] browser" : ""}`, active: focus === "editor", children: visibleEditorRows.map((row, index) => {
816
+ return _jsxs(Text, { wrap: "truncate-end", color: selected ? theme.focus : entry.kind === "directory" ? theme.directory : entry.path === openFilePath ? theme.success : theme.text, bold: selected || entry.kind === "directory", children: [selected ? "> " : " ", indentation, marker, truncate(entry.name, width)] }, `${entry.kind}:${entry.path}`);
817
+ }), !fileTree.length && _jsx(Text, { color: theme.muted, children: "No workspace files found." })] });
818
+ const editorPanel = _jsx(Panel, { title: `${editorTitle} [up/down] scroll [d] diff${previewUrl ? " [o] browser" : ""}`, active: focus === "editor", theme: theme, children: visibleEditorRows.map((row, index) => {
757
819
  const lineLabel = row.continuation ? " |" : String(row.sourceLine).padStart(3);
758
- return _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: "gray", children: lineLabel }), " ", row.tokens.map((token, tokenIndex) => _jsx(Text, { color: token.color, dimColor: token.dim, children: token.text }, `${tokenIndex}-${token.text}`))] }, `${editorScroll + index}-${row.sourceLine}-${row.continuation}`);
820
+ return _jsxs(Text, { wrap: "truncate-end", color: theme.text, children: [_jsx(Text, { color: theme.muted, children: lineLabel }), " ", row.tokens.map((token, tokenIndex) => _jsx(Text, { color: token.color ? theme.syntax[token.color] : theme.text, dimColor: token.dim, children: token.text }, `${tokenIndex}-${token.text}`))] }, `${editorScroll + index}-${row.sourceLine}-${row.continuation}`);
759
821
  }) });
760
- const agentPanel = _jsxs(Panel, { title: chatScroll ? `AGENT [${chatScroll} lines above]` : "AGENT [Enter] send", active: focus === "chat", children: [activePlan && _jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Text, { bold: true, color: "cyan", wrap: "truncate-end", children: ["PLAN: ", truncate(activePlan.title, Math.max(8, chatWidth - 9))] }), activePlan.steps.slice(0, 3).map((step) => _jsxs(Text, { wrap: "truncate-end", color: step.status === "completed" ? "green" : step.status === "in_progress" ? "yellow" : "gray", children: [step.status === "completed" ? "[x]" : step.status === "in_progress" ? "[..]" : "[ ]", " ", truncate(step.content, Math.max(8, chatWidth - 7))] }, step.id))] }), chatLines.map((line, index) => _jsx(Text, { color: line.role === "user" ? "cyan" : "green", bold: line.header, children: line.text }, `${line.role}-${line.header}-${index}-${line.text}`)), _jsx(Box, { flexGrow: 1 }), _jsxs(Text, { wrap: "truncate-end", color: focus === "chat" ? "cyan" : "gray", children: [focus === "chat" ? "> " : " ", truncate(chatInput || (busy ? "Working... Escape cancels" : "Ask about this workspace"), Math.max(12, chatWidth - 5))] })] });
761
- return _jsxs(Box, { flexDirection: "column", height: viewport.rows, paddingX: 1, overflow: "hidden", children: [_jsxs(Box, { height: 1, justifyContent: "space-between", marginBottom: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: brand.productName.toUpperCase() }), _jsx(Text, { color: "gray", children: configuration ? `${configuration.provider} / ${configuration.model}` : "No model selected" }), _jsx(Text, { color: runStatus === "waiting" ? "yellow" : busy ? "cyan" : "green", children: runStatus === "waiting" ? "APPROVAL" : runStatus === "tool" ? "TOOL" : busy ? "WORKING" : "READY" })] }), _jsxs(Box, { height: 1, justifyContent: "space-between", marginBottom: 1, children: [_jsxs(Text, { color: "gray", children: ["CONTEXT ", _jsxs(Text, { color: contextTokens / contextWindow >= 0.9 ? "red" : contextTokens / contextWindow >= 0.7 ? "yellow" : "cyan", children: [formatTokenCount(contextTokens), " / ", formatTokenCount(contextWindow)] }), " estimated"] }), _jsxs(Text, { color: "gray", children: ["SPEED ", _jsx(Text, { color: busy ? "green" : "gray", children: tokensPerSecond ? `${tokensPerSecond.toFixed(1)} tok/s` : "-- tok/s" }), previewUrl ? ` PREVIEW ${truncate(previewUrl, Math.max(16, Math.floor(viewport.columns * 0.3)))}` : ""] })] }), compactLayout ? _jsxs(Box, { height: workspaceHeight, flexDirection: "column", gap: 1, overflow: "hidden", children: [_jsxs(Box, { height: editorHeight, flexDirection: "row", gap: 1, overflow: "hidden", children: [_jsx(Box, { width: filesWidth, height: "100%", children: filesPanel }), _jsx(Box, { width: editorWidth, height: "100%", children: editorPanel })] }), _jsx(Box, { height: compactChatHeight, width: "100%", children: agentPanel })] }) : _jsxs(Box, { height: workspaceHeight, flexDirection: "row", gap: 1, overflow: "hidden", children: [_jsx(Box, { width: filesWidth, height: "100%", children: filesPanel }), _jsx(Box, { width: editorWidth, height: "100%", children: editorPanel }), _jsx(Box, { width: chatWidth, height: "100%", children: agentPanel })] }), _jsx(Box, { height: terminalHeight, marginTop: 1, children: _jsxs(Panel, { title: "TERMINAL [Enter] run", active: focus === "terminal", children: [visibleTerminalLines.map((line, index) => _jsx(Text, { wrap: "truncate-end", color: line.startsWith("[agent error]") || line.startsWith("[terminal error]") ? "red" : line.startsWith("[tool") ? "yellow" : "gray", children: truncate(line, Math.max(20, viewport.columns - 8)) }, `${index}-${line}`)), _jsx(Box, { flexGrow: 1 }), _jsxs(Text, { wrap: "truncate-end", color: focus === "terminal" ? "cyan" : "gray", children: [focus === "terminal" ? "> " : " ", truncate(commandInput || "Type a workspace command", Math.max(16, viewport.columns - 10))] })] }) }), _jsxs(Box, { height: 1, marginTop: 1, justifyContent: "space-between", children: [_jsx(Text, { color: "gray", wrap: "truncate-end", children: "Tab/Shift+Tab focus | Ctrl+Left/Right pane | / find | m model | Esc stop" }), _jsx(Text, { color: "gray", children: sessionId ? `session ${sessionId.slice(0, 8)}` : "new session" })] }), screen === "file-search" && _jsxs(Box, { position: "absolute", flexDirection: "column", borderStyle: "double", borderColor: "cyan", paddingX: 2, paddingY: 1, width: overlayWidth, marginLeft: 2, marginTop: 3, backgroundColor: "black", children: [_jsx(Text, { bold: true, color: "cyan", children: "FIND FILE" }), _jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: "> " }), fileSearchInput || _jsx(Text, { color: "gray", children: "Type part of a file name or path" })] }), _jsx(Text, { color: "gray", children: "Up/Down select Enter open Escape close" }), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [fileSearchResults.map((entry, index) => {
822
+ const agentPanel = _jsxs(Panel, { title: chatScroll ? `AGENT [${chatScroll} lines above]` : "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" ? theme.success : step.status === "in_progress" ? theme.warning : theme.muted, children: [step.status === "completed" ? "[x]" : step.status === "in_progress" ? "[..]" : "[ ]", " ", truncate(step.content, Math.max(8, chatWidth - 7))] }, step.id))] }), chatLines.map((line, index) => _jsx(Text, { color: line.role === "user" ? theme.user : theme.agent, bold: line.header, children: line.text }, `${line.role}-${line.header}-${index}-${line.text}`)), _jsx(Box, { flexGrow: 1 }), _jsxs(Text, { wrap: "truncate-end", color: focus === "chat" ? theme.focus : theme.muted, children: [focus === "chat" ? "> " : " ", truncate(chatInput || (busy ? "Working... Escape cancels" : "Ask about this workspace"), Math.max(12, chatWidth - 5))] })] });
823
+ 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 ? `${configuration.provider} / ${configuration.model}` : "No model selected" }), _jsx(Text, { color: runStatus === "waiting" ? theme.warning : busy ? theme.focus : theme.success, children: runStatus === "waiting" ? "APPROVAL" : runStatus === "tool" ? "TOOL" : busy ? "WORKING" : "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 ? theme.error : contextTokens / contextWindow >= 0.7 ? theme.warning : 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 ? `${tokensPerSecond.toFixed(1)} tok/s` : "-- tok/s" }), previewUrl ? ` PREVIEW ${truncate(previewUrl, Math.max(16, Math.floor(viewport.columns * 0.3)))}` : ""] })] }), compactLayout ? _jsxs(Box, { height: workspaceHeight, flexDirection: "column", gap: 1, overflow: "hidden", children: [_jsxs(Box, { height: editorHeight, flexDirection: "row", gap: 1, overflow: "hidden", children: [_jsx(Box, { width: filesWidth, height: "100%", children: filesPanel }), _jsx(Box, { width: editorWidth, height: "100%", children: editorPanel })] }), _jsx(Box, { height: compactChatHeight, width: "100%", children: agentPanel })] }) : _jsxs(Box, { height: workspaceHeight, flexDirection: "row", gap: 1, overflow: "hidden", children: [_jsx(Box, { width: filesWidth, height: "100%", children: filesPanel }), _jsx(Box, { width: editorWidth, height: "100%", children: editorPanel }), _jsx(Box, { width: chatWidth, height: "100%", children: agentPanel })] }), _jsx(Box, { height: terminalHeight, marginTop: 1, children: _jsxs(Panel, { title: "TERMINAL [Enter] run", active: focus === "terminal", theme: theme, children: [visibleTerminalLines.map((line, index) => _jsx(Text, { wrap: "truncate-end", color: line.startsWith("[agent error]") || line.startsWith("[terminal error]") ? theme.error : line.startsWith("[tool") ? theme.warning : theme.muted, children: truncate(line, Math.max(20, viewport.columns - 8)) }, `${index}-${line}`)), _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 | m settings | Esc cancel run | Ctrl+C exit idle" }), _jsx(Text, { color: theme.muted, children: sessionId ? `session ${sessionId.slice(0, 8)}` : "new session" })] }), 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) => {
762
824
  const selected = index === Math.min(fileSearchIndex, Math.max(0, fileSearchResults.length - 1));
763
825
  const fileName = entry.path.split("/").at(-1) ?? entry.path;
764
- return _jsxs(Text, { color: selected ? "cyan" : undefined, bold: selected, children: [selected ? "> " : " ", fileName, _jsxs(Text, { color: "gray", children: [" ", truncate(entry.path, Math.max(12, overlayWidth - fileName.length - 10))] })] }, entry.path);
765
- }), !fileSearchResults.length && _jsx(Text, { color: "yellow", children: "No matching files." })] })] }), screen === "settings" && _jsxs(Box, { position: "absolute", flexDirection: "column", borderStyle: "double", borderColor: "cyan", paddingX: 2, paddingY: 1, width: overlayWidth, marginLeft: 2, marginTop: 3, backgroundColor: "black", children: [_jsx(Text, { bold: true, color: "cyan", children: "LOCAL MODEL CONFIGURATION" }), _jsx(Text, { color: "gray", children: "Tab changes fields. Enter selects an item or saves when endpoint and model are set." }), _jsxs(Text, { color: settingsField === "server" ? "cyan" : undefined, children: ["SERVER: ", selectedEndpoint?.label ?? "Scanning local servers..."] }), _jsxs(Text, { color: settingsField === "endpoint" ? "cyan" : undefined, children: ["ENDPOINT: ", endpointInput] }), _jsxs(Text, { color: settingsField === "model" ? "cyan" : undefined, children: ["MODEL: ", modelInput || models[modelIndex] || "Choose or type a model"] }), _jsxs(Text, { color: settingsField === "internet" ? "cyan" : undefined, children: ["INTERNET RESEARCH: ", internetAccess ? "enabled" : "disabled", " ", settingsField === "internet" ? "[Space toggles]" : ""] }), _jsxs(Text, { color: "gray", children: ["Detected models: ", models.slice(0, 5).join(", ") || "none; type one manually"] }), _jsxs(Text, { color: "yellow", children: ["Provider: ", providerKind, ". Use the server selector for Ollama or compatible endpoints."] })] }), screen === "approval" && _jsxs(Box, { position: "absolute", flexDirection: "column", borderStyle: "double", borderColor: "yellow", paddingX: 2, paddingY: 1, width: overlayWidth, marginLeft: 2, marginTop: 8, backgroundColor: "black", children: [_jsx(Text, { bold: true, color: "yellow", children: "TOOL APPROVAL REQUIRED" }), _jsxs(Text, { children: [pendingTool?.name, " ", JSON.stringify(pendingTool?.input)] }), _jsx(Text, { color: "gray", children: "Press Y or Enter to allow. Press N or Escape to deny." })] }), screen === "help" && _jsxs(Box, { position: "absolute", flexDirection: "column", borderStyle: "double", borderColor: "cyan", paddingX: 2, paddingY: 1, width: overlayWidth, marginLeft: 2, marginTop: 7, backgroundColor: "black", children: [_jsx(Text, { bold: true, color: "cyan", children: "WORKSPACE COMMANDS" }), workspaceCommandHelp().split("\n").slice(1).map((line) => _jsx(Text, { children: line }, line)), _jsx(Text, { color: "gray", children: "Type slash commands in the Agent or Terminal pane. Press any key to close." })] })] });
826
+ 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);
827
+ }), !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, ". Use the server selector for Ollama or compatible endpoints."] })] }), 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 === "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().split("\n").slice(1).map((line) => _jsx(Text, { color: theme.text, children: line }, line)), _jsx(Text, { color: theme.muted, children: "Press any key to close this reference." })] })] });
766
828
  }
767
829
  const initialConfiguration = await resolveConfiguration({ workspaceRoot: cwd() }).catch((error) => {
768
830
  const message = error instanceof Error ? error.message : String(error);
@@ -0,0 +1,22 @@
1
+ import type { SyntaxToken } from "./file-browser.js";
2
+ export declare const tuiThemeNames: readonly ["forest", "sage", "dusk"];
3
+ export type TuiThemeName = (typeof tuiThemeNames)[number];
4
+ export interface TuiTheme {
5
+ readonly name: TuiThemeName;
6
+ readonly panel: string;
7
+ readonly focus: string;
8
+ readonly accent: string;
9
+ readonly muted: string;
10
+ readonly text: string;
11
+ readonly directory: string;
12
+ readonly agent: string;
13
+ readonly user: string;
14
+ readonly success: string;
15
+ readonly warning: string;
16
+ readonly error: string;
17
+ readonly overlay: string;
18
+ readonly syntax: Readonly<Record<NonNullable<SyntaxToken["color"]>, string>>;
19
+ }
20
+ export declare const tuiThemes: Readonly<Record<TuiThemeName, TuiTheme>>;
21
+ export declare function isTuiThemeName(value: unknown): value is TuiThemeName;
22
+ export declare function tuiTheme(value: TuiThemeName | undefined): TuiTheme;
package/dist/theme.js ADDED
@@ -0,0 +1,57 @@
1
+ export const tuiThemeNames = ["forest", "sage", "dusk"];
2
+ export const tuiThemes = {
3
+ forest: {
4
+ name: "forest",
5
+ panel: "#416b4c",
6
+ focus: "#7fc58a",
7
+ accent: "#a4dca0",
8
+ muted: "#7e9e81",
9
+ text: "#d8e6d7",
10
+ directory: "#8fba7e",
11
+ agent: "#b4d59a",
12
+ user: "#77bd8a",
13
+ success: "#91ca92",
14
+ warning: "#d3b978",
15
+ error: "#d79a8e",
16
+ overlay: "#13231d",
17
+ syntax: { blue: "#9eb4cf", cyan: "#8cc8bd", gray: "#71867d", green: "#a9c994", magenta: "#c4a8c8", red: "#d4958a", yellow: "#d6c184" }
18
+ },
19
+ sage: {
20
+ name: "sage",
21
+ panel: "#5d7666",
22
+ focus: "#a5c6ab",
23
+ accent: "#b3d5ba",
24
+ muted: "#8e9f91",
25
+ text: "#dce5dd",
26
+ directory: "#9bbcab",
27
+ agent: "#bad1a0",
28
+ user: "#8ab9b0",
29
+ success: "#9dca9c",
30
+ warning: "#d6ba83",
31
+ error: "#d69b91",
32
+ overlay: "#1a241d",
33
+ syntax: { blue: "#9eafcf", cyan: "#89c0b4", gray: "#7d8d80", green: "#afcc9a", magenta: "#c5aac4", red: "#d5958d", yellow: "#d7c183" }
34
+ },
35
+ dusk: {
36
+ name: "dusk",
37
+ panel: "#3d665d",
38
+ focus: "#78aa95",
39
+ accent: "#8cc2aa",
40
+ muted: "#728a82",
41
+ text: "#d1dfda",
42
+ directory: "#7eaaa0",
43
+ agent: "#a7c38e",
44
+ user: "#7cadba",
45
+ success: "#83bb91",
46
+ warning: "#ceb776",
47
+ error: "#d29289",
48
+ overlay: "#101d1c",
49
+ syntax: { blue: "#93aaca", cyan: "#7eb9ae", gray: "#687d76", green: "#9dbf8c", magenta: "#bda2c3", red: "#cf9188", yellow: "#d0ba80" }
50
+ }
51
+ };
52
+ export function isTuiThemeName(value) {
53
+ return typeof value === "string" && tuiThemeNames.includes(value);
54
+ }
55
+ export function tuiTheme(value) {
56
+ return tuiThemes[value ?? "forest"];
57
+ }
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@truss-harness/tui",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Interactive terminal client for Truss.",
5
5
  "type": "module",
6
6
  "bin": {
7
- "truss-tui": "./dist/bin.js"
7
+ "truss-tui": "dist/bin.js"
8
8
  },
9
9
  "scripts": {
10
10
  "build": "tsc -b",
@@ -43,7 +43,7 @@
43
43
  "ink": "^6.8.0",
44
44
  "react": "^19.2.0",
45
45
  "@truss-harness/branding": "0.1.0",
46
- "@truss-harness/cli": "0.1.0",
46
+ "@truss-harness/cli": "0.1.3",
47
47
  "@truss-harness/provider-openai-compatible": "0.1.0",
48
48
  "@truss-harness/runtime": "0.1.0"
49
49
  },