@truss-harness/tui 0.1.20 → 0.1.22

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 CHANGED
@@ -5,7 +5,8 @@ import { cwd } from "node:process";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { brand } from "@truss-harness/branding";
7
7
  import { FileAgentProfileStore } from "@truss-harness/cli/agents";
8
- import { resolveConfiguration, } from "@truss-harness/cli/config";
8
+ import { MissingModelError, resolveConfiguration, } from "@truss-harness/cli/config";
9
+ import { systemSecretStore } from "@truss-harness/cli/secrets";
9
10
  import { detectLocalEndpoints, isLocalEndpointKind, listLocalModels, } from "@truss-harness/provider-openai-compatible";
10
11
  import { render, useApp } from "ink";
11
12
  import { useEffect, useMemo, useReducer, useState } from "react";
@@ -14,6 +15,7 @@ import { useTuiInputController } from "./input-controller.js";
14
15
  import { calculateLayout, focusReducer } from "./layout.js";
15
16
  import { openExternalPreview } from "./processes.js";
16
17
  import { useRuntimeSessionController } from "./runtime-session.js";
18
+ import { useSkillsController } from "./skills-controller.js";
17
19
  import { useTerminalController } from "./terminal-controller.js";
18
20
  import { tuiTheme } from "./theme.js";
19
21
  import { useWorkspaceFiles } from "./workspace-files.js";
@@ -110,6 +112,7 @@ function App({ initialConfiguration, }) {
110
112
  closeSearch: () => setScreen("workspace"),
111
113
  });
112
114
  const { fileIndex, setFileIndex, fileTree, selectedFileTreeEntry, fileSearchInput, setFileSearchInput, fileSearchIndex, setFileSearchIndex, fileSearchResults, openFilePath, editorTitle, setEditorScroll, editorRows, visibleEditorRows, fileTreeStart, visibleFileTree, loadFile, toggleDirectory, openSearchResult, toggleDiff, } = workspaceFiles;
115
+ const skillsController = useSkillsController(workspaceRoot, initialConfiguration?.skills);
113
116
  const runtimeSession = useRuntimeSessionController({
114
117
  initialConfiguration,
115
118
  workspaceRoot,
@@ -123,6 +126,7 @@ function App({ initialConfiguration, }) {
123
126
  contextWindow,
124
127
  appendTerminal,
125
128
  showScreen: setScreen,
129
+ skills: skillsController.skills,
126
130
  });
127
131
  const { configuration, mcpStatuses, sessionId, chatInput, setChatInput, chat, chatScroll, setChatScroll, busy, runStatus, activePlan, pendingTool, tokensPerSecond, configureRuntime, sendPrompt, resolveApproval, startNewConversation, cancelRun, testMcpConnections, } = runtimeSession;
128
132
  const contextTokens = useMemo(() => chat.reduce((total, message) => total + estimateTokens(message.content), 400), [chat]);
@@ -226,6 +230,7 @@ function App({ initialConfiguration, }) {
226
230
  editorLineCount,
227
231
  chatTranscriptLength: chatTranscript.length,
228
232
  chatLineCount,
233
+ chatInput,
229
234
  },
230
235
  setters: {
231
236
  setScreen,
@@ -262,6 +267,15 @@ function App({ initialConfiguration, }) {
262
267
  loadFile: (entry) => void loadFile(entry),
263
268
  toggleDiff: () => void toggleDiff(),
264
269
  sendPrompt: () => void sendPrompt(),
270
+ openSkills: () => {
271
+ skillsController.refreshSkills();
272
+ setScreen("skills");
273
+ },
274
+ moveSkill: skillsController.moveSkill,
275
+ toggleSkill: skillsController.toggleSkill,
276
+ cycleSkillMode: skillsController.cycleSkillMode,
277
+ toggleSuperpowers: skillsController.toggleSuperpowers,
278
+ toggleSkillScope: skillsController.toggleSkillScope,
265
279
  },
266
280
  });
267
281
  return (_jsx(TuiWorkspaceView, { viewport: viewport, layout: layout, theme: theme, configuration: configuration, runStatus: runStatus, busy: busy, contextTokens: contextTokens, contextWindow: contextWindow, tokensPerSecond: tokensPerSecond, previewUrl: previewUrl, focus: focus, visibleFileTree: visibleFileTree, fileTreeLength: fileTree.length, fileTreeStart: fileTreeStart, fileIndex: fileIndex, openFilePath: openFilePath, editorTitle: editorTitle, visibleEditorRows: visibleEditorRows, activePlan: activePlan, chatLines: chatLines, chatScroll: chatScroll, chatInput: chatInput, visibleTerminalLines: visibleTerminalLines, commandInput: commandInput, sessionId: sessionId, overlays: {
@@ -282,13 +296,22 @@ function App({ initialConfiguration, }) {
282
296
  pendingTool,
283
297
  agentProfiles,
284
298
  mcpStatuses,
299
+ skills: {
300
+ state: skillsController.skillState,
301
+ index: skillsController.skillIndex,
302
+ scope: skillsController.skillScope,
303
+ ...(skillsController.skillError
304
+ ? { error: skillsController.skillError }
305
+ : {}),
306
+ },
285
307
  } }));
286
308
  }
309
+ const secrets = systemSecretStore();
287
310
  const initialConfiguration = await resolveConfiguration({
288
311
  workspaceRoot: cwd(),
312
+ ...(secrets ? { secrets } : {}),
289
313
  }).catch((error) => {
290
- const message = error instanceof Error ? error.message : String(error);
291
- if (message.startsWith("Set a model"))
314
+ if (error instanceof MissingModelError)
292
315
  return undefined;
293
316
  throw error;
294
317
  });
@@ -1,6 +1,8 @@
1
+ import type { RuntimeServiceSkillState } from "@truss-harness/cli/protocol";
1
2
  import type { LocalModelEndpoint } from "@truss-harness/provider-openai-compatible";
2
3
  import type { AgentProfile, ToolCall } from "@truss-harness/runtime";
3
4
  import type { FileEntry } from "./file-browser.js";
5
+ import { type SkillScope } from "./skills-controller.js";
4
6
  import type { TuiTheme, TuiThemeName } from "./theme.js";
5
7
  import type { Screen, SettingsField } from "./types.js";
6
8
  export declare function Panel({ title, active, theme, children, }: {
@@ -39,6 +41,13 @@ export interface TuiOverlaysProps {
39
41
  readonly pendingTool?: ToolCall;
40
42
  readonly agentProfiles: readonly AgentProfile[];
41
43
  readonly mcpStatuses: readonly McpServerView[];
44
+ readonly skills?: TuiSkillsView;
45
+ }
46
+ export interface TuiSkillsView {
47
+ readonly state?: RuntimeServiceSkillState;
48
+ readonly index: number;
49
+ readonly scope: SkillScope;
50
+ readonly error?: string;
42
51
  }
43
52
  export declare function TuiOverlays(props: TuiOverlaysProps): React.ReactElement;
44
53
  export {};
@@ -1,7 +1,9 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { brand } from "@truss-harness/branding";
2
3
  import { workspaceCommandHelp } from "@truss-harness/runtime";
3
4
  import { Box, Text } from "ink";
4
5
  import { truncate } from "./display.js";
6
+ import { skillPickerRows } from "./skills-controller.js";
5
7
  export function Panel({ title, active, theme, children, }) {
6
8
  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
9
  }
@@ -22,6 +24,10 @@ const tuiControlHelp = [
22
24
  "AGENT",
23
25
  "Enter sends; a opens saved multi-agent profiles; Up/Down scroll chat; n starts a new conversation outside the chat input.",
24
26
  ],
27
+ [
28
+ "SKILLS",
29
+ "s outside chat, or /skills in chat, opens the skill picker; Space selects, M mode, P Superpowers, W scope.",
30
+ ],
25
31
  [
26
32
  "MCP",
27
33
  "c opens credential-safe server and tool status; R tests configured servers.",
@@ -40,8 +46,11 @@ const tuiControlHelp = [
40
46
  "Escape cancels an active agent run. Ctrl+C stops a process or run, and exits while idle.",
41
47
  ],
42
48
  ];
49
+ const visibleSkillRows = 12;
43
50
  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;
51
+ const { screen, theme, overlayWidth, fileSearchInput, fileSearchResults, fileSearchIndex, settingsField, selectedEndpoint, endpointInput, modelInput, models, modelIndex, internetAccess, themeName, providerKind, pendingTool, agentProfiles, mcpStatuses, skills, } = props;
52
+ const skillRows = skills?.state ? skillPickerRows(skills.state) : [];
53
+ const skillStart = Math.max(0, Math.min((skills?.index ?? 0) - Math.floor(visibleSkillRows / 2), skillRows.length - visibleSkillRows));
45
54
  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
55
  const selected = index ===
47
56
  Math.min(fileSearchIndex, Math.max(0, fileSearchResults.length - 1));
@@ -53,7 +62,16 @@ export function TuiOverlays(props) {
53
62
  ? theme.success
54
63
  : theme.warning, children: [_jsx(Text, { bold: true, children: server.name }), " ", server.state, server.error
55
64
  ? ` — ${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()
65
+ : ` — ${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 === "skills" && (_jsxs(Box, { position: "absolute", flexDirection: "column", borderStyle: "double", borderColor: theme.focus, paddingX: 2, paddingY: 1, width: overlayWidth, marginLeft: 2, marginTop: 4, backgroundColor: theme.overlay, children: [_jsx(Text, { bold: true, color: theme.accent, children: "SKILLS" }), skills?.state ? (_jsxs(Text, { color: theme.text, children: ["Mode ", skills.state.selection.mode, " \u00B7 Superpowers", " ", skills.state.selection.superpowersEnabled ? "on" : "off", " \u00B7 Saving for", " ", skills.scope === "user" ? "all workspaces" : "this workspace"] })) : (_jsx(Text, { color: theme.muted, children: "Loading skills..." })), _jsx(Box, { flexDirection: "column", marginTop: 1, children: skillRows
66
+ .slice(skillStart, skillStart + visibleSkillRows)
67
+ .map((row, offset) => {
68
+ const active = skillStart + offset === skills?.index;
69
+ return (_jsxs(Text, { color: active
70
+ ? theme.focus
71
+ : row.available
72
+ ? theme.text
73
+ : theme.muted, wrap: "truncate-end", children: [active ? "> " : " ", "[", row.selected ? "x" : " ", "] ", row.id, row.note ? ` (${row.note})` : "", " ", row.description] }, row.id));
74
+ }) }), skills?.error && _jsx(Text, { color: theme.warning, children: skills.error }), _jsx(Text, { color: theme.muted, wrap: "wrap", children: `Space marks a skill always active. M cycles Automatic, Manual, Off. P toggles Superpowers. W switches all workspaces or this one. Changes apply to the next run. Import with \`${brand.cliCommand} skills add <folder>\`. S 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
75
  .split("\n")
58
76
  .slice(1)
59
77
  .map((line) => (_jsx(Text, { color: theme.text, children: line }, line))), _jsx(Text, { color: theme.muted, children: "Press any key to close this reference." })] }))] }));
@@ -32,7 +32,7 @@ export function buildFileTree(files, collapsed) {
32
32
  kind: "file",
33
33
  path: file.path,
34
34
  name: file.path.split("/").at(-1) ?? file.path,
35
- depth
35
+ depth,
36
36
  });
37
37
  }
38
38
  };
@@ -68,12 +68,38 @@ export function fuzzyFiles(files, query, limit = 12) {
68
68
  const score = fuzzyScore(file.path, query);
69
69
  return score === undefined ? [] : [{ file, score }];
70
70
  })
71
- .sort((left, right) => left.score - right.score || left.file.path.localeCompare(right.file.path))
71
+ .sort((left, right) => left.score - right.score ||
72
+ left.file.path.localeCompare(right.file.path))
72
73
  .slice(0, limit)
73
74
  .map(({ file }) => file);
74
75
  }
75
- const codeExtensions = new Set([".c", ".cc", ".cpp", ".cs", ".go", ".java", ".js", ".jsx", ".kt", ".mjs", ".php", ".py", ".rb", ".rs", ".sh", ".ts", ".tsx"]);
76
- const markupExtensions = new Set([".html", ".htm", ".svg", ".vue", ".svelte", ".xml"]);
76
+ const codeExtensions = new Set([
77
+ ".c",
78
+ ".cc",
79
+ ".cpp",
80
+ ".cs",
81
+ ".go",
82
+ ".java",
83
+ ".js",
84
+ ".jsx",
85
+ ".kt",
86
+ ".mjs",
87
+ ".php",
88
+ ".py",
89
+ ".rb",
90
+ ".rs",
91
+ ".sh",
92
+ ".ts",
93
+ ".tsx",
94
+ ]);
95
+ const markupExtensions = new Set([
96
+ ".html",
97
+ ".htm",
98
+ ".svg",
99
+ ".vue",
100
+ ".svelte",
101
+ ".xml",
102
+ ]);
77
103
  const dataExtensions = new Set([".json", ".jsonc", ".yaml", ".yml", ".toml"]);
78
104
  function tokenize(line, pattern, colors) {
79
105
  const result = [];
@@ -83,7 +109,11 @@ function tokenize(line, pattern, colors) {
83
109
  if (index > cursor)
84
110
  result.push({ text: line.slice(cursor, index) });
85
111
  const group = match.slice(1).findIndex((value) => value !== undefined);
86
- result.push({ text: match[0], color: colors[Math.max(0, group)], dim: group === 0 });
112
+ result.push({
113
+ text: match[0],
114
+ color: colors[Math.max(0, group)],
115
+ dim: group === 0,
116
+ });
87
117
  cursor = index + match[0].length;
88
118
  }
89
119
  if (cursor < line.length)
@@ -105,7 +135,13 @@ export function syntaxTokens(line, path) {
105
135
  const comment = extension === ".py" || extension === ".rb" || extension === ".sh"
106
136
  ? /(#.*$)|("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`)|(\b(?:as|async|await|break|case|catch|class|const|continue|def|default|delete|do|else|enum|export|extends|false|finally|fn|for|from|function|if|implements|import|in|instanceof|interface|let|match|new|null|package|private|protected|public|return|static|struct|super|switch|this|throw|true|try|type|typeof|undefined|use|var|void|while|yield)\b)|(\b(?:0x[\da-f]+|\d+(?:\.\d+)?)\b)|([{}()[\].,:;=+\-*/<>!?&|]+)/gi
107
137
  : /(\/\/.*$)|("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`)|(\b(?:as|async|await|break|case|catch|class|const|continue|def|default|delete|do|else|enum|export|extends|false|finally|fn|for|from|function|if|implements|import|in|instanceof|interface|let|match|new|null|package|private|protected|public|return|static|struct|super|switch|this|throw|true|try|type|typeof|undefined|use|var|void|while|yield)\b)|(\b(?:0x[\da-f]+|\d+(?:\.\d+)?)\b)|([{}()[\].,:;=+\-*/<>!?&|]+)/gi;
108
- return tokenize(line, comment, ["gray", "green", "magenta", "yellow", "cyan"]);
138
+ return tokenize(line, comment, [
139
+ "gray",
140
+ "green",
141
+ "magenta",
142
+ "yellow",
143
+ "cyan",
144
+ ]);
109
145
  }
110
146
  return [{ text: line }];
111
147
  }
@@ -28,6 +28,7 @@ interface InputState {
28
28
  readonly editorLineCount: number;
29
29
  readonly chatTranscriptLength: number;
30
30
  readonly chatLineCount: number;
31
+ readonly chatInput: string;
31
32
  }
32
33
  interface InputSetters {
33
34
  readonly setScreen: Setter<Screen>;
@@ -64,6 +65,12 @@ interface InputActions {
64
65
  readonly loadFile: (entry: FileEntry) => void;
65
66
  readonly toggleDiff: () => void;
66
67
  readonly sendPrompt: () => void;
68
+ readonly openSkills: () => void;
69
+ readonly moveSkill: (delta: number) => void;
70
+ readonly toggleSkill: () => void;
71
+ readonly cycleSkillMode: () => void;
72
+ readonly toggleSuperpowers: () => void;
73
+ readonly toggleSkillScope: () => void;
67
74
  }
68
75
  export interface TuiInputControllerOptions {
69
76
  readonly state: InputState;
@@ -56,6 +56,25 @@ export function useTuiInputController({ state, setters, actions, }) {
56
56
  actions.refreshAgentProfiles();
57
57
  return;
58
58
  }
59
+ if (state.screen === "skills") {
60
+ if (key.escape || input === "s") {
61
+ setters.setScreen("workspace");
62
+ return;
63
+ }
64
+ if (key.upArrow)
65
+ actions.moveSkill(-1);
66
+ else if (key.downArrow)
67
+ actions.moveSkill(1);
68
+ else if (input === " " || key.return)
69
+ actions.toggleSkill();
70
+ else if (input.toLowerCase() === "m")
71
+ actions.cycleSkillMode();
72
+ else if (input.toLowerCase() === "p")
73
+ actions.toggleSuperpowers();
74
+ else if (input.toLowerCase() === "w")
75
+ actions.toggleSkillScope();
76
+ return;
77
+ }
59
78
  if (state.screen === "mcp") {
60
79
  if (key.escape || input.toLowerCase() === "c") {
61
80
  setters.setScreen("workspace");
@@ -193,6 +212,10 @@ export function useTuiInputController({ state, setters, actions, }) {
193
212
  setters.setScreen("mcp");
194
213
  return;
195
214
  }
215
+ if (input === "s" && state.focus !== "chat") {
216
+ actions.openSkills();
217
+ return;
218
+ }
196
219
  if (input === "n" && state.focus !== "chat") {
197
220
  actions.startNewConversation();
198
221
  return;
@@ -240,6 +263,11 @@ export function useTuiInputController({ state, setters, actions, }) {
240
263
  return;
241
264
  }
242
265
  if (key.return) {
266
+ if (state.chatInput.trim() === "/skills") {
267
+ setters.setChatInput("");
268
+ actions.openSkills();
269
+ return;
270
+ }
243
271
  actions.sendPrompt();
244
272
  return;
245
273
  }
@@ -16,8 +16,10 @@ export interface RuntimeSessionOptions {
16
16
  readonly contextWindow: number;
17
17
  readonly appendTerminal: (output: string) => void;
18
18
  readonly showScreen: (screen: Screen) => void;
19
+ /** Shared skill wiring for every runtime this session creates. */
20
+ readonly skills: () => Promise<ResolvedConfiguration["skills"]>;
19
21
  }
20
- export declare function useRuntimeSessionController({ initialConfiguration, workspaceRoot, providerKind, endpointInput, modelInput, agentMode, permissionMode, internetAccess, openFilePath, contextWindow, appendTerminal, showScreen, }: RuntimeSessionOptions): {
22
+ export declare function useRuntimeSessionController({ initialConfiguration, workspaceRoot, providerKind, endpointInput, modelInput, agentMode, permissionMode, internetAccess, openFilePath, contextWindow, appendTerminal, showScreen, skills, }: RuntimeSessionOptions): {
21
23
  configuration: ClientConfiguration | undefined;
22
24
  mcpStatuses: readonly import("@truss-harness/mcp").McpServerStatus[];
23
25
  sessionId: string | undefined;
@@ -5,7 +5,7 @@ import { cloudProviderDefinition, isCloudProviderId, } from "@truss-harness/prov
5
5
  import { executeWorkspaceCommand, FileWorkspacePlanStore, } from "@truss-harness/runtime";
6
6
  import { useEffect, useRef, useState } from "react";
7
7
  import { estimateTokens, truncate } from "./display.js";
8
- export function useRuntimeSessionController({ initialConfiguration, workspaceRoot, providerKind, endpointInput, modelInput, agentMode, permissionMode, internetAccess, openFilePath, contextWindow, appendTerminal, showScreen, }) {
8
+ export function useRuntimeSessionController({ initialConfiguration, workspaceRoot, providerKind, endpointInput, modelInput, agentMode, permissionMode, internetAccess, openFilePath, contextWindow, appendTerminal, showScreen, skills, }) {
9
9
  const [configuration, setConfiguration] = useState(initialConfiguration);
10
10
  const [client, setClient] = useState();
11
11
  const [mcpStatuses, setMcpStatuses] = useState(() => Object.entries(initialConfiguration?.mcpServers ?? {}).map(([name, server]) => ({
@@ -109,6 +109,7 @@ export function useRuntimeSessionController({ initialConfiguration, workspaceRoo
109
109
  mode: agentMode,
110
110
  internetAccess,
111
111
  mcpServers: initialConfiguration?.mcpServers,
112
+ skills: await skills(),
112
113
  approval: {
113
114
  approve: async (call) => {
114
115
  const readOnly = [
@@ -0,0 +1,36 @@
1
+ import { type ResolvedConfiguration } from "@truss-harness/cli/config";
2
+ import type { RuntimeServiceSkillState } from "@truss-harness/cli/protocol";
3
+ import type { SkillMode, SkillSelection } from "@truss-harness/runtime";
4
+ export type SkillScope = "user" | "workspace";
5
+ type DefaultSkills = ResolvedConfiguration["skills"];
6
+ export interface SkillPickerRow {
7
+ readonly id: string;
8
+ readonly description: string;
9
+ readonly selected: boolean;
10
+ readonly available: boolean;
11
+ readonly note?: string;
12
+ }
13
+ export declare function skillPickerRows(state: RuntimeServiceSkillState): SkillPickerRow[];
14
+ export declare function nextSkillMode(mode: SkillMode): SkillMode;
15
+ /** Space on a row: unavailable skills can be cleared but never added. */
16
+ export declare function toggledSkillSelection(state: RuntimeServiceSkillState, row: SkillPickerRow): Partial<SkillSelection> | undefined;
17
+ /** Turning the pack off drops selected pack skills so runs stay valid. */
18
+ export declare function superpowersSelection(selection: SkillSelection): Partial<SkillSelection>;
19
+ /**
20
+ * Keyboard skill picker state. Skills come from the same user store and
21
+ * settings file as the CLI, so changes apply to every client's next run.
22
+ */
23
+ export declare function useSkillsController(workspaceRoot: string, configured?: DefaultSkills): {
24
+ refreshSkills: () => void;
25
+ moveSkill: (delta: number) => void;
26
+ toggleSkill: () => void;
27
+ cycleSkillMode: () => void;
28
+ toggleSuperpowers: () => void;
29
+ toggleSkillScope: () => void;
30
+ skills: () => Promise<DefaultSkills>;
31
+ skillState: RuntimeServiceSkillState | undefined;
32
+ skillIndex: number;
33
+ skillScope: SkillScope;
34
+ skillError: string | undefined;
35
+ };
36
+ export {};
@@ -0,0 +1,102 @@
1
+ import { resolveSkills, } from "@truss-harness/cli/config";
2
+ import { skillServiceHost } from "@truss-harness/cli/skills";
3
+ import { useCallback, useMemo, useRef, useState } from "react";
4
+ const superpowersPrefix = "superpowers:";
5
+ const modes = ["automatic", "manual", "off"];
6
+ export function skillPickerRows(state) {
7
+ const selected = new Set(state.selection.skillIds);
8
+ return state.skills.map((skill) => {
9
+ const packOff = skill.id.startsWith(superpowersPrefix) &&
10
+ !state.selection.superpowersEnabled;
11
+ const note = !skill.trusted
12
+ ? "untrusted: import to use"
13
+ : packOff
14
+ ? "pack off"
15
+ : undefined;
16
+ return {
17
+ id: skill.id,
18
+ description: skill.description,
19
+ selected: selected.has(skill.id),
20
+ available: skill.trusted && !packOff,
21
+ ...(note ? { note } : {}),
22
+ };
23
+ });
24
+ }
25
+ export function nextSkillMode(mode) {
26
+ return modes[(modes.indexOf(mode) + 1) % modes.length] ?? "automatic";
27
+ }
28
+ /** Space on a row: unavailable skills can be cleared but never added. */
29
+ export function toggledSkillSelection(state, row) {
30
+ if (!row.selected && !row.available)
31
+ return undefined;
32
+ const rest = state.selection.skillIds.filter((id) => id !== row.id);
33
+ return { skillIds: row.selected ? rest : [...rest, row.id] };
34
+ }
35
+ /** Turning the pack off drops selected pack skills so runs stay valid. */
36
+ export function superpowersSelection(selection) {
37
+ return selection.superpowersEnabled
38
+ ? {
39
+ superpowersEnabled: false,
40
+ skillIds: selection.skillIds.filter((id) => !id.startsWith(superpowersPrefix)),
41
+ }
42
+ : { superpowersEnabled: true };
43
+ }
44
+ /**
45
+ * Keyboard skill picker state. Skills come from the same user store and
46
+ * settings file as the CLI, so changes apply to every client's next run.
47
+ */
48
+ export function useSkillsController(workspaceRoot, configured) {
49
+ const resolved = useRef(undefined);
50
+ const skills = useCallback(() => {
51
+ if (configured)
52
+ return Promise.resolve(configured);
53
+ resolved.current ??= resolveSkills({ workspaceRoot });
54
+ return resolved.current;
55
+ }, [configured, workspaceRoot]);
56
+ const [skillState, setSkillState] = useState();
57
+ const [skillIndex, setSkillIndex] = useState(0);
58
+ const [skillScope, setSkillScope] = useState("user");
59
+ const [skillError, setSkillError] = useState();
60
+ const run = useCallback((action) => {
61
+ void skills()
62
+ .then((value) => action(skillServiceHost(value, workspaceRoot)))
63
+ .then((next) => {
64
+ setSkillState(next);
65
+ setSkillError(undefined);
66
+ setSkillIndex((index) => Math.min(index, Math.max(0, next.skills.length - 1)));
67
+ })
68
+ .catch((error) => setSkillError(error instanceof Error ? error.message : String(error)));
69
+ }, [skills, workspaceRoot]);
70
+ const actions = useMemo(() => {
71
+ const save = (selection) => {
72
+ if (selection)
73
+ run((host) => host.setSettings(skillScope, undefined, selection));
74
+ };
75
+ return {
76
+ refreshSkills: () => run((host) => host.list()),
77
+ moveSkill: (delta) => setSkillIndex((index) => Math.min(Math.max(0, index + delta), Math.max(0, (skillState?.skills.length ?? 1) - 1))),
78
+ toggleSkill: () => {
79
+ const row = skillState && skillPickerRows(skillState)[skillIndex];
80
+ if (skillState && row)
81
+ save(toggledSkillSelection(skillState, row));
82
+ },
83
+ cycleSkillMode: () => {
84
+ if (skillState)
85
+ save({ mode: nextSkillMode(skillState.selection.mode) });
86
+ },
87
+ toggleSuperpowers: () => {
88
+ if (skillState)
89
+ save(superpowersSelection(skillState.selection));
90
+ },
91
+ toggleSkillScope: () => setSkillScope((scope) => (scope === "user" ? "workspace" : "user")),
92
+ };
93
+ }, [run, skillIndex, skillScope, skillState]);
94
+ return {
95
+ skills,
96
+ skillState,
97
+ skillIndex,
98
+ skillScope,
99
+ skillError,
100
+ ...actions,
101
+ };
102
+ }
package/dist/theme.js CHANGED
@@ -14,7 +14,15 @@ export const tuiThemes = {
14
14
  warning: "#d3b978",
15
15
  error: "#d79a8e",
16
16
  overlay: "#13231d",
17
- syntax: { blue: "#9eb4cf", cyan: "#8cc8bd", gray: "#71867d", green: "#a9c994", magenta: "#c4a8c8", red: "#d4958a", yellow: "#d6c184" }
17
+ syntax: {
18
+ blue: "#9eb4cf",
19
+ cyan: "#8cc8bd",
20
+ gray: "#71867d",
21
+ green: "#a9c994",
22
+ magenta: "#c4a8c8",
23
+ red: "#d4958a",
24
+ yellow: "#d6c184",
25
+ },
18
26
  },
19
27
  sage: {
20
28
  name: "sage",
@@ -30,7 +38,15 @@ export const tuiThemes = {
30
38
  warning: "#d6ba83",
31
39
  error: "#d69b91",
32
40
  overlay: "#1a241d",
33
- syntax: { blue: "#9eafcf", cyan: "#89c0b4", gray: "#7d8d80", green: "#afcc9a", magenta: "#c5aac4", red: "#d5958d", yellow: "#d7c183" }
41
+ syntax: {
42
+ blue: "#9eafcf",
43
+ cyan: "#89c0b4",
44
+ gray: "#7d8d80",
45
+ green: "#afcc9a",
46
+ magenta: "#c5aac4",
47
+ red: "#d5958d",
48
+ yellow: "#d7c183",
49
+ },
34
50
  },
35
51
  dusk: {
36
52
  name: "dusk",
@@ -46,11 +62,19 @@ export const tuiThemes = {
46
62
  warning: "#ceb776",
47
63
  error: "#d29289",
48
64
  overlay: "#101d1c",
49
- syntax: { blue: "#93aaca", cyan: "#7eb9ae", gray: "#687d76", green: "#9dbf8c", magenta: "#bda2c3", red: "#cf9188", yellow: "#d0ba80" }
50
- }
65
+ syntax: {
66
+ blue: "#93aaca",
67
+ cyan: "#7eb9ae",
68
+ gray: "#687d76",
69
+ green: "#9dbf8c",
70
+ magenta: "#bda2c3",
71
+ red: "#cf9188",
72
+ yellow: "#d0ba80",
73
+ },
74
+ },
51
75
  };
52
76
  export function isTuiThemeName(value) {
53
- return typeof value === "string" && tuiThemeNames.includes(value);
77
+ return (typeof value === "string" && tuiThemeNames.includes(value));
54
78
  }
55
79
  export function tuiTheme(value) {
56
80
  return tuiThemes[value ?? "forest"];
package/dist/types.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { SyntaxToken } from "./file-browser.js";
2
2
  export declare const focusOrder: readonly ["files", "editor", "chat", "terminal"];
3
3
  export type Focus = (typeof focusOrder)[number];
4
- export type Screen = "workspace" | "settings" | "approval" | "help" | "file-search" | "agents" | "mcp";
4
+ export type Screen = "workspace" | "settings" | "approval" | "help" | "file-search" | "agents" | "mcp" | "skills";
5
5
  export type SettingsField = "server" | "endpoint" | "model" | "internet" | "theme";
6
6
  export type RunStatus = "ready" | "thinking" | "tool" | "waiting";
7
7
  export interface ChatMessage {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@truss-harness/tui",
3
- "version": "0.1.20",
3
+ "version": "0.1.22",
4
4
  "description": "Interactive terminal client for Truss.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -17,7 +17,7 @@
17
17
  "logo.png"
18
18
  ],
19
19
  "engines": {
20
- "node": ">=20"
20
+ "node": ">=22.15.1"
21
21
  },
22
22
  "license": "SEE LICENSE IN LICENSE",
23
23
  "repository": {
@@ -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.23",
47
- "@truss-harness/provider-openai-compatible": "^0.1.14",
48
- "@truss-harness/runtime": "^0.1.12"
46
+ "@truss-harness/cli": "^0.1.25",
47
+ "@truss-harness/provider-openai-compatible": "^0.1.17",
48
+ "@truss-harness/runtime": "^0.1.15"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@types/react": "^19.2.0"