omniharness-cli 0.1.77 → 0.1.79
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/agent/mastraEngine.js +21 -3
- package/dist/plugins.js +235 -0
- package/dist/skills.js +4 -0
- package/dist/ui/home.js +83 -0
- package/dist/ui/terminalInterface.js +42 -4
- package/package.json +1 -1
|
@@ -5,7 +5,8 @@ import { attachmentBlock, kindFromName } from '../attachments.js';
|
|
|
5
5
|
import { OmniRouteClient } from '../config/omniRoute.js';
|
|
6
6
|
import { saveActiveCombo } from '../config/settings.js';
|
|
7
7
|
import { createSystemTools } from '../tools/systemTools.js';
|
|
8
|
-
import { loadSkills, renderSkillCommand, skillSchema } from '../skills.js';
|
|
8
|
+
import { loadSkills, renderSkillCommand, skillKind, skillSchema } from '../skills.js';
|
|
9
|
+
import { loadPluginSkills, renderCommandBody } from '../plugins.js';
|
|
9
10
|
import { chunkText, cosineSimilarity } from '../search.js';
|
|
10
11
|
import { loadSemanticIndex, saveSemanticIndex } from '../semanticStore.js';
|
|
11
12
|
import { loadSession, saveSession, clearSession } from '../sessionStore.js';
|
|
@@ -122,7 +123,14 @@ export async function createMastraEngine(config) {
|
|
|
122
123
|
};
|
|
123
124
|
const emit = (event) => { for (const listener of listeners)
|
|
124
125
|
listener(event); };
|
|
125
|
-
|
|
126
|
+
// OMNIHARNESS.md skills first: a workspace's own definition wins a name
|
|
127
|
+
// collision with a plugin's.
|
|
128
|
+
const ownSkills = await loadSkills(config.workspaceRoot);
|
|
129
|
+
const taken = new Set(ownSkills.map((skill) => skill.name));
|
|
130
|
+
const skills = [
|
|
131
|
+
...ownSkills,
|
|
132
|
+
...(await loadPluginSkills(config.workspaceRoot)).filter((skill) => !taken.has(skill.name)),
|
|
133
|
+
];
|
|
126
134
|
const systemTools = {
|
|
127
135
|
read_file: {
|
|
128
136
|
name: 'read_file', description: tools.readFile.description, highRisk: false, parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] },
|
|
@@ -228,9 +236,19 @@ export async function createMastraEngine(config) {
|
|
|
228
236
|
}
|
|
229
237
|
};
|
|
230
238
|
for (const skill of skills) {
|
|
239
|
+
const prompt = skillKind(skill) === 'prompt';
|
|
231
240
|
systemTools[skill.name] = {
|
|
232
|
-
name: skill.name,
|
|
241
|
+
name: skill.name,
|
|
242
|
+
description: skill.description,
|
|
243
|
+
// A prompt skill returns instructions. It runs nothing itself, so it
|
|
244
|
+
// does not need the shell and does not carry the shell's risk — the
|
|
245
|
+
// tools it then asks for are gated on their own terms.
|
|
246
|
+
highRisk: !prompt,
|
|
247
|
+
parameters: skillSchema(skill).parameters ?? { type: 'object', properties: {} },
|
|
233
248
|
execute: async (input) => {
|
|
249
|
+
if (prompt) {
|
|
250
|
+
return renderCommandBody(skill.prompt ?? '', String(input.arguments ?? ''));
|
|
251
|
+
}
|
|
234
252
|
if (!config.shellAllowed)
|
|
235
253
|
throw new Error('shell execution is disabled; custom skills need shell access');
|
|
236
254
|
const script = renderSkillCommand(skill.command, skill.parameters, input);
|
package/dist/plugins.js
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
/**
|
|
5
|
+
* Split leading `---` delimited frontmatter from a markdown body.
|
|
6
|
+
*
|
|
7
|
+
* Deliberately a small key/value reader rather than a YAML parser: command
|
|
8
|
+
* frontmatter in practice is flat scalars, and a dependency that can evaluate
|
|
9
|
+
* arbitrary YAML is a poor thing to point at files the user downloaded. A file
|
|
10
|
+
* with no frontmatter is not an error — it is a prompt with no metadata.
|
|
11
|
+
*/
|
|
12
|
+
export function parseFrontmatter(text) {
|
|
13
|
+
const normalized = text.replace(/^/, '');
|
|
14
|
+
const match = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/.exec(normalized);
|
|
15
|
+
if (!match)
|
|
16
|
+
return { fields: {}, body: normalized.trim() };
|
|
17
|
+
const fields = {};
|
|
18
|
+
for (const line of match[1].split(/\r?\n/)) {
|
|
19
|
+
const kv = /^([A-Za-z0-9_-]+)[ \t]*:[ \t]*(.*)$/.exec(line);
|
|
20
|
+
if (!kv)
|
|
21
|
+
continue;
|
|
22
|
+
let value = kv[2].trim();
|
|
23
|
+
// Strip one layer of matching quotes, the only quoting seen in practice.
|
|
24
|
+
if ((value.startsWith('"') && value.endsWith('"') && value.length > 1)
|
|
25
|
+
|| (value.startsWith("'") && value.endsWith("'") && value.length > 1)) {
|
|
26
|
+
value = value.slice(1, -1);
|
|
27
|
+
}
|
|
28
|
+
fields[kv[1].toLowerCase()] = value;
|
|
29
|
+
}
|
|
30
|
+
return { fields, body: normalized.slice(match[0].length).trim() };
|
|
31
|
+
}
|
|
32
|
+
function splitList(value) {
|
|
33
|
+
if (!value)
|
|
34
|
+
return [];
|
|
35
|
+
const inner = value.trim().replace(/^\[/, '').replace(/\]$/, '');
|
|
36
|
+
// Split on commas that are not inside the parentheses of Bash(gh pr view:*).
|
|
37
|
+
const out = [];
|
|
38
|
+
let depth = 0;
|
|
39
|
+
let current = '';
|
|
40
|
+
for (const ch of inner) {
|
|
41
|
+
if (ch === '(')
|
|
42
|
+
depth += 1;
|
|
43
|
+
if (ch === ')')
|
|
44
|
+
depth = Math.max(0, depth - 1);
|
|
45
|
+
if (ch === ',' && depth === 0) {
|
|
46
|
+
out.push(current);
|
|
47
|
+
current = '';
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
current += ch;
|
|
51
|
+
}
|
|
52
|
+
out.push(current);
|
|
53
|
+
return out.map((entry) => entry.trim().replace(/^["']|["']$/g, '')).filter((entry) => entry !== '');
|
|
54
|
+
}
|
|
55
|
+
async function readJSON(file) {
|
|
56
|
+
try {
|
|
57
|
+
return JSON.parse(await fs.readFile(file, 'utf8'));
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
async function readCommands(root, pluginName, dir) {
|
|
64
|
+
const base = path.join(root, dir);
|
|
65
|
+
let entries;
|
|
66
|
+
try {
|
|
67
|
+
entries = await fs.readdir(base);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return [];
|
|
71
|
+
}
|
|
72
|
+
const commands = [];
|
|
73
|
+
for (const entry of entries.sort()) {
|
|
74
|
+
if (!entry.toLowerCase().endsWith('.md'))
|
|
75
|
+
continue;
|
|
76
|
+
const file = path.join(base, entry);
|
|
77
|
+
let raw;
|
|
78
|
+
try {
|
|
79
|
+
raw = await fs.readFile(file, 'utf8');
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
const { fields, body } = parseFrontmatter(raw);
|
|
85
|
+
if (body === '')
|
|
86
|
+
continue; // nothing to instruct with
|
|
87
|
+
commands.push({
|
|
88
|
+
name: entry.replace(/\.md$/i, ''),
|
|
89
|
+
description: fields.description ?? `${dir.replace(/s$/, '')} from ${pluginName}`,
|
|
90
|
+
body,
|
|
91
|
+
allowedTools: splitList(fields['allowed-tools']),
|
|
92
|
+
plugin: pluginName,
|
|
93
|
+
path: file,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
return commands;
|
|
97
|
+
}
|
|
98
|
+
/** Read one plugin directory. Returns null if it is not a plugin. */
|
|
99
|
+
export async function loadPlugin(root) {
|
|
100
|
+
const manifest = await readJSON(path.join(root, '.claude-plugin', 'plugin.json'));
|
|
101
|
+
if (!manifest || typeof manifest.name !== 'string' || manifest.name === '')
|
|
102
|
+
return null;
|
|
103
|
+
const name = manifest.name;
|
|
104
|
+
const author = manifest.author;
|
|
105
|
+
const commands = [
|
|
106
|
+
...await readCommands(root, name, 'commands'),
|
|
107
|
+
...await readCommands(root, name, 'agents'),
|
|
108
|
+
];
|
|
109
|
+
return {
|
|
110
|
+
name,
|
|
111
|
+
description: typeof manifest.description === 'string' ? manifest.description : '',
|
|
112
|
+
version: typeof manifest.version === 'string' ? manifest.version : '0.0.0',
|
|
113
|
+
author: typeof author === 'string' ? author : (author?.name ?? ''),
|
|
114
|
+
root,
|
|
115
|
+
commands,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
async function subdirectories(dir) {
|
|
119
|
+
try {
|
|
120
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
121
|
+
return entries.filter((e) => e.isDirectory()).map((e) => path.join(dir, e.name)).sort();
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return [];
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Read a directory as either a single plugin or a marketplace of them. A
|
|
129
|
+
* marketplace's `source` entries are relative to the marketplace root; a
|
|
130
|
+
* source pointing outside it is ignored rather than followed.
|
|
131
|
+
*/
|
|
132
|
+
export async function loadFrom(dir, depth = 2) {
|
|
133
|
+
const single = await loadPlugin(dir);
|
|
134
|
+
if (single)
|
|
135
|
+
return [single];
|
|
136
|
+
const market = await readJSON(path.join(dir, '.claude-plugin', 'marketplace.json'));
|
|
137
|
+
const out = [];
|
|
138
|
+
if (market && Array.isArray(market.plugins)) {
|
|
139
|
+
for (const entry of market.plugins) {
|
|
140
|
+
const source = typeof entry.source === 'string' ? entry.source : '';
|
|
141
|
+
if (source === '')
|
|
142
|
+
continue;
|
|
143
|
+
const resolved = path.resolve(dir, source);
|
|
144
|
+
const rel = path.relative(dir, resolved);
|
|
145
|
+
if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel))
|
|
146
|
+
continue;
|
|
147
|
+
const plugin = await loadPlugin(resolved);
|
|
148
|
+
if (plugin)
|
|
149
|
+
out.push(plugin);
|
|
150
|
+
}
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
// A directory of plugin directories — and a child may itself be a
|
|
154
|
+
// marketplace rather than a plugin, which is what cloning a plugin
|
|
155
|
+
// repository into the plugins directory gives you. Descending only one
|
|
156
|
+
// level found nothing in that very ordinary case. Bounded, so a plugins
|
|
157
|
+
// directory holding a deep source tree is not walked to the bottom.
|
|
158
|
+
if (depth <= 0)
|
|
159
|
+
return out;
|
|
160
|
+
for (const child of await subdirectories(dir)) {
|
|
161
|
+
out.push(...await loadFrom(child, depth - 1));
|
|
162
|
+
}
|
|
163
|
+
return out;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* The directories searched for plugins, nearest first.
|
|
167
|
+
*
|
|
168
|
+
* OMNIHARNESS_PLUGIN_PATH replaces the list entirely, delimited like PATH.
|
|
169
|
+
* Setting it empty disables discovery. It exists because the default reaches
|
|
170
|
+
* into the user's home directory: without an override, what the agent can do
|
|
171
|
+
* depends on what happens to be installed on the machine, which makes a test
|
|
172
|
+
* or a CI run read differently from one box to the next.
|
|
173
|
+
*/
|
|
174
|
+
export function pluginSearchPath(workspaceRoot) {
|
|
175
|
+
const override = process.env.OMNIHARNESS_PLUGIN_PATH;
|
|
176
|
+
if (override !== undefined) {
|
|
177
|
+
return override.split(path.delimiter).map((entry) => entry.trim()).filter((entry) => entry !== '');
|
|
178
|
+
}
|
|
179
|
+
const home = os.homedir();
|
|
180
|
+
return [
|
|
181
|
+
path.join(workspaceRoot, '.claude', 'plugins'),
|
|
182
|
+
path.join(workspaceRoot, '.omniharness', 'plugins'),
|
|
183
|
+
...(home ? [path.join(home, '.claude', 'plugins')] : []),
|
|
184
|
+
];
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Discover every plugin on the search path. A workspace plugin wins a name
|
|
188
|
+
* collision with a user-level one, because the nearer definition is the more
|
|
189
|
+
* specific.
|
|
190
|
+
*/
|
|
191
|
+
export async function discoverPlugins(workspaceRoot, roots = pluginSearchPath(workspaceRoot)) {
|
|
192
|
+
const seen = new Set();
|
|
193
|
+
const out = [];
|
|
194
|
+
for (const root of roots) {
|
|
195
|
+
for (const plugin of await loadFrom(root)) {
|
|
196
|
+
if (seen.has(plugin.name))
|
|
197
|
+
continue;
|
|
198
|
+
seen.add(plugin.name);
|
|
199
|
+
out.push(plugin);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return out;
|
|
203
|
+
}
|
|
204
|
+
/** Substitute the argument tokens a command body may use. */
|
|
205
|
+
export function renderCommandBody(body, args) {
|
|
206
|
+
const words = args.trim() === '' ? [] : args.trim().split(/\s+/);
|
|
207
|
+
let out = body.split('$ARGUMENTS').join(args.trim());
|
|
208
|
+
// $1..$9 take one word each, matching the shell convention the format borrows.
|
|
209
|
+
out = out.replace(/\$([1-9])/g, (_, digit) => words[Number(digit) - 1] ?? '');
|
|
210
|
+
return out;
|
|
211
|
+
}
|
|
212
|
+
const ARGUMENTS_PARAM = [{ name: 'arguments', type: 'string' }];
|
|
213
|
+
/**
|
|
214
|
+
* Present a plugin command as a Skill so the rest of the engine does not have
|
|
215
|
+
* to know where it came from. Names are prefixed with the plugin so two
|
|
216
|
+
* plugins can both ship a "review" command.
|
|
217
|
+
*/
|
|
218
|
+
export function commandToSkill(command) {
|
|
219
|
+
return {
|
|
220
|
+
name: `${command.plugin}:${command.name}`.replace(/[^A-Za-z0-9_:.-]/g, '-'),
|
|
221
|
+
description: command.description,
|
|
222
|
+
command: '',
|
|
223
|
+
parameters: ARGUMENTS_PARAM,
|
|
224
|
+
kind: 'prompt',
|
|
225
|
+
prompt: command.body,
|
|
226
|
+
source: command.plugin,
|
|
227
|
+
allowedTools: command.allowedTools,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
/** Every command from every discovered plugin, as Skills. */
|
|
231
|
+
export async function loadPluginSkills(workspaceRoot) {
|
|
232
|
+
const plugins = await discoverPlugins(workspaceRoot);
|
|
233
|
+
return plugins.flatMap((plugin) => plugin.commands.map(commandToSkill));
|
|
234
|
+
}
|
|
235
|
+
//# sourceMappingURL=plugins.js.map
|
package/dist/skills.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { promises as fs } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
/** Whether a skill runs a command or supplies instructions. */
|
|
4
|
+
export function skillKind(skill) {
|
|
5
|
+
return skill.kind ?? 'shell';
|
|
6
|
+
}
|
|
3
7
|
/**
|
|
4
8
|
* Parse an OMNIHARNESS.md skills definition. Blocks start with `## <name>`
|
|
5
9
|
* and contain `description:`, `command:`, and `param: <name> <type>` fields.
|
package/dist/ui/home.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Data shaping for the home screen — the layout itself lives in the Ink
|
|
3
|
+
* component, but everything it has to decide is pure and lives here so it can
|
|
4
|
+
* be tested without rendering a terminal.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* Render an age the way someone reads it at a glance: the largest unit that
|
|
8
|
+
* still says something useful, never more than one unit deep.
|
|
9
|
+
*
|
|
10
|
+
* Anything not yet a minute old is "now" rather than "0m ago", because a
|
|
11
|
+
* session saved seconds ago reading as zero looks like a bug.
|
|
12
|
+
*/
|
|
13
|
+
export function relativeTime(savedAt, now = Date.now()) {
|
|
14
|
+
const then = Date.parse(savedAt);
|
|
15
|
+
if (!Number.isFinite(then))
|
|
16
|
+
return '';
|
|
17
|
+
const seconds = Math.max(0, Math.round((now - then) / 1000));
|
|
18
|
+
if (seconds < 60)
|
|
19
|
+
return 'now';
|
|
20
|
+
const minutes = Math.floor(seconds / 60);
|
|
21
|
+
if (minutes < 60)
|
|
22
|
+
return `${minutes}m ago`;
|
|
23
|
+
const hours = Math.floor(minutes / 60);
|
|
24
|
+
if (hours < 24)
|
|
25
|
+
return `${hours}h ago`;
|
|
26
|
+
const days = Math.floor(hours / 24);
|
|
27
|
+
if (days < 7)
|
|
28
|
+
return `${days}d ago`;
|
|
29
|
+
const weeks = Math.floor(days / 7);
|
|
30
|
+
if (weeks < 52)
|
|
31
|
+
return `${weeks}w ago`;
|
|
32
|
+
return `${Math.floor(days / 365)}y ago`;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The recent-session rows to show, newest first and capped. Names are clipped
|
|
36
|
+
* rather than wrapped: a single line per session keeps the block a predictable
|
|
37
|
+
* height, which matters because the home screen shares the window with the
|
|
38
|
+
* input.
|
|
39
|
+
*/
|
|
40
|
+
export function recentRows(sessions, limit, nameWidth, now = Date.now()) {
|
|
41
|
+
return sessions.slice(0, Math.max(0, limit)).map((session) => ({
|
|
42
|
+
name: clipName(session.name, nameWidth),
|
|
43
|
+
age: relativeTime(session.savedAt, now),
|
|
44
|
+
}));
|
|
45
|
+
}
|
|
46
|
+
function clipName(name, width) {
|
|
47
|
+
const runes = [...name];
|
|
48
|
+
if (width <= 1 || runes.length <= width)
|
|
49
|
+
return name;
|
|
50
|
+
return `${runes.slice(0, width - 1).join('')}…`;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Whether there is room for the two-column home. Below this the columns would
|
|
54
|
+
* be too narrow to hold a session name or a path, so the blocks stack instead.
|
|
55
|
+
*/
|
|
56
|
+
export const TWO_COLUMN_MIN_WIDTH = 76;
|
|
57
|
+
export function twoColumn(width) {
|
|
58
|
+
return width >= TWO_COLUMN_MIN_WIDTH;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Shorten a workspace path from the left, keeping the end. The tail is the
|
|
62
|
+
* part that identifies the project; the head is usually /Users/someone.
|
|
63
|
+
*/
|
|
64
|
+
export function shortenPath(p, width) {
|
|
65
|
+
if (p.length <= width)
|
|
66
|
+
return p;
|
|
67
|
+
if (width <= 1)
|
|
68
|
+
return p.slice(-width);
|
|
69
|
+
return `…${p.slice(-(width - 1))}`;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* A one-line summary of what the agent can reach beyond its built-in tools.
|
|
73
|
+
* Says nothing at all when there is nothing to say, rather than "0 skills".
|
|
74
|
+
*/
|
|
75
|
+
export function capabilityLine(skills, plugins, mcpTools) {
|
|
76
|
+
const parts = [];
|
|
77
|
+
if (skills > 0)
|
|
78
|
+
parts.push(`${skills} skill${skills === 1 ? '' : 's'}${plugins > 0 ? ` from ${plugins} plugin${plugins === 1 ? '' : 's'}` : ''}`);
|
|
79
|
+
if (mcpTools > 0)
|
|
80
|
+
parts.push(`${mcpTools} mcp tool${mcpTools === 1 ? '' : 's'}`);
|
|
81
|
+
return parts.join(' · ');
|
|
82
|
+
}
|
|
83
|
+
//# sourceMappingURL=home.js.map
|
|
@@ -8,6 +8,7 @@ import { deleteAt, deleteBefore, insertAt, layoutEditor, lineEndAt, lineStartAt,
|
|
|
8
8
|
import { renderMarkdown } from './markdown.js';
|
|
9
9
|
import { looksLikeDiff, diffSegments } from './diff.js';
|
|
10
10
|
import { palette } from './palette.js';
|
|
11
|
+
import { capabilityLine, recentRows, shortenPath, twoColumn } from './home.js';
|
|
11
12
|
import { contextMeter, meterBar } from './modelWindows.js';
|
|
12
13
|
import { BEL, SYNC_QUERY, isSyncOutputReply, osc9Notify, osc52Copy, shouldNudgeOnFinish, wrapSynchronizedOutput } from './termcaps.js';
|
|
13
14
|
import { KITTY_POP, KITTY_PUSH, KITTY_QUERY, isEncodedKey, isKittyQueryResponse, parseRawKey } from './keys.js';
|
|
@@ -173,9 +174,39 @@ function TranscriptEntry({ line, width, fallbackModel }) {
|
|
|
173
174
|
const bullet = line.role === 'user' ? '>' : line.role === 'error' ? '!' : '-';
|
|
174
175
|
return _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { bold: true, color: colorFor(line.role), children: [bullet, " ", label] }), rows.map((segments, index) => _jsx(SegmentText, { segments: segments, role: line.role }, index)), line.saved ? _jsxs(Text, { dimColor: true, children: [" ", line.saved] }) : null] });
|
|
175
176
|
}
|
|
176
|
-
/**
|
|
177
|
-
|
|
178
|
-
|
|
177
|
+
/**
|
|
178
|
+
* Width of the dim label column on the home screen. The value gets whatever is
|
|
179
|
+
* left, and every caller has to subtract this: a value sized to the whole box
|
|
180
|
+
* wraps onto a second line and the block stops being a fixed height.
|
|
181
|
+
*/
|
|
182
|
+
const LABEL_WIDTH = 10;
|
|
183
|
+
/** One labelled line: a dim fixed-width label, then the value. */
|
|
184
|
+
function Field({ label, children }) {
|
|
185
|
+
return _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: label.padEnd(LABEL_WIDTH) }), children] });
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* The home screen, shown while the transcript is empty.
|
|
189
|
+
*
|
|
190
|
+
* Two columns when the terminal is wide enough — what this session is on the
|
|
191
|
+
* left, what you can pick up and what you can press on the right — stacking
|
|
192
|
+
* below that rather than squeezing. Everything on it is read from the running
|
|
193
|
+
* session: there is no placeholder copy and no invented "what's new" feed,
|
|
194
|
+
* because a home screen that shows things which are not true is worse than a
|
|
195
|
+
* plain one.
|
|
196
|
+
*/
|
|
197
|
+
export function Hero(props) {
|
|
198
|
+
const { width, endpoint, model, mode, perm, workspace, sessions, skills, plugins, mcpTools } = props;
|
|
199
|
+
const wide = twoColumn(width);
|
|
200
|
+
const outer = Math.min(width - 2, 84);
|
|
201
|
+
const column = wide ? Math.floor((outer - 3) / 2) : outer;
|
|
202
|
+
const inner = Math.max(12, column - 4);
|
|
203
|
+
// Values sit to the right of the label, so that is the room they actually get.
|
|
204
|
+
const value = Math.max(8, inner - LABEL_WIDTH);
|
|
205
|
+
const capability = capabilityLine(skills, plugins, mcpTools);
|
|
206
|
+
const recent = recentRows(sessions, 4, Math.max(8, inner - 9));
|
|
207
|
+
const session = _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: PALETTE.accent, paddingX: 1, width: column, children: [_jsxs(Text, { bold: true, color: PALETTE.accent, children: ["omniharness ", ownVersion()] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Field, { label: "workspace", children: shortenPath(workspace, value) }), _jsx(Field, { label: "gateway", children: shortenPath(endpoint, value) }), _jsx(Field, { label: "model", children: clip(model, value) }), _jsx(Field, { label: "mode", children: _jsx(Text, { color: MODE_ACCENT[mode], children: mode }) }), _jsx(Field, { label: "perms", children: _jsx(Text, { color: mode === 'crazy' ? PALETTE.error : PERM_COLOR(perm), children: mode === 'crazy' ? 'bypass' : PERM_LABEL[perm] }) }), capability !== '' && _jsx(Field, { label: "loaded", children: clip(capability, value) })] })] });
|
|
208
|
+
const aside = _jsxs(Box, { flexDirection: "column", width: column, marginLeft: wide ? 1 : 0, marginTop: wide ? 0 : 1, children: [recent.length > 0 && _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: PALETTE.muted, paddingX: 1, marginBottom: 1, children: [_jsx(Text, { bold: true, dimColor: true, children: "recent" }), recent.map((row) => _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: row.age.padEnd(8) }), row.name] }, row.name)), _jsx(Text, { dimColor: true, children: "/sessions for all" })] }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: PALETTE.muted, paddingX: 1, children: [_jsx(Text, { bold: true, dimColor: true, children: "keys" }), _jsxs(Text, { children: [_jsx(Text, { color: PALETTE.accent, children: 'Ctrl+E'.padEnd(LABEL_WIDTH) }), _jsx(Text, { dimColor: true, children: "cycle mode" })] }), _jsxs(Text, { children: [_jsx(Text, { color: PALETTE.accent, children: 'Shift+Tab'.padEnd(LABEL_WIDTH) }), _jsx(Text, { dimColor: true, children: "cycle perms" })] }), _jsxs(Text, { children: [_jsx(Text, { color: PALETTE.accent, children: 'Ctrl+O'.padEnd(LABEL_WIDTH) }), _jsx(Text, { dimColor: true, children: "pick a model" })] }), _jsx(Text, { dimColor: true, children: "/help for the rest" })] })] });
|
|
209
|
+
return _jsxs(Box, { flexDirection: "column", marginBottom: 1, width: outer, children: [_jsxs(Box, { flexDirection: wide ? 'row' : 'column', alignItems: "flex-start", children: [session, aside] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "describe the work and press enter" }) })] });
|
|
179
210
|
}
|
|
180
211
|
export function TerminalInterface({ engine }) {
|
|
181
212
|
const { exit } = useApp();
|
|
@@ -216,6 +247,9 @@ export function TerminalInterface({ engine }) {
|
|
|
216
247
|
const [now, setNow] = useState(() => Date.now());
|
|
217
248
|
const queuedRef = useRef(null);
|
|
218
249
|
const [queued, setQueued] = useState();
|
|
250
|
+
// Recent snapshots for the home screen. Read once on mount and left
|
|
251
|
+
// alone: the home screen is only on screen before the first turn.
|
|
252
|
+
const [recentSessions, setRecentSessions] = useState([]);
|
|
219
253
|
const [layoutDebug, setLayoutDebug] = useState(false);
|
|
220
254
|
const syncRestoreRef = useRef(null);
|
|
221
255
|
const pushLine = (line) => setLines((current) => [...current, line]);
|
|
@@ -226,6 +260,10 @@ export function TerminalInterface({ engine }) {
|
|
|
226
260
|
if (alive && promptHistoryRef.current.length === 0)
|
|
227
261
|
syncPromptHistory(history);
|
|
228
262
|
}).catch(() => { });
|
|
263
|
+
void listSessions(engine.state.workspace.root).then((found) => {
|
|
264
|
+
if (alive)
|
|
265
|
+
setRecentSessions(found);
|
|
266
|
+
}).catch(() => { });
|
|
229
267
|
return () => { alive = false; };
|
|
230
268
|
}, []);
|
|
231
269
|
useEffect(() => {
|
|
@@ -895,7 +933,7 @@ export function TerminalInterface({ engine }) {
|
|
|
895
933
|
const liveThinkView = liveThinkLines.slice(-Math.max(2, Math.floor(liveBudget / 2)));
|
|
896
934
|
const liveAnswerView = liveAnswerLines.slice(-liveBudget);
|
|
897
935
|
const doneAgents = agents.filter((lane) => lane.status === 'done').length;
|
|
898
|
-
return _jsxs(Box, { flexDirection: "column", width: width, paddingX: 2, children: [_jsx(Static, { items: lines, children: (line, index) => _jsx(TranscriptEntry, { line: line, width: contentWidth, fallbackModel: engine.state.activeModel }, index) }, staticKey), _jsxs(Box, { flexDirection: "column", children: [lines.length === 0 && !busy && _jsx(Hero, { width: width, endpoint: engine.client.endpoint ?? 'omniroute', model: engine.state.activeModel, mode: mode, perm: permMode }), liveThink !== '' && _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { bold: true, color: PALETTE.warn, children: "\u00B7 thinking" }), liveThinkView.map((segments, index) => _jsx(SegmentText, { segments: segments, role: "thinking" }, index))] }), liveAnswer !== '' && _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { bold: true, color: PALETTE.accent, children: engine.state.activeModel }), liveAnswerView.map((segments, index) => _jsx(SegmentText, { segments: segments, role: "assistant" }, index))] }), toolCards.slice(-5).map((card) => {
|
|
936
|
+
return _jsxs(Box, { flexDirection: "column", width: width, paddingX: 2, children: [_jsx(Static, { items: lines, children: (line, index) => _jsx(TranscriptEntry, { line: line, width: contentWidth, fallbackModel: engine.state.activeModel }, index) }, staticKey), _jsxs(Box, { flexDirection: "column", children: [lines.length === 0 && !busy && _jsx(Hero, { width: width, endpoint: engine.client.endpoint ?? 'omniroute', model: engine.state.activeModel, mode: mode, perm: permMode, workspace: engine.state.workspace.root, sessions: recentSessions, skills: engine.skills.length, plugins: new Set(engine.skills.map((skill) => skill.source).filter((source) => source !== undefined)).size, mcpTools: engine.mcpTools.length }), liveThink !== '' && _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { bold: true, color: PALETTE.warn, children: "\u00B7 thinking" }), liveThinkView.map((segments, index) => _jsx(SegmentText, { segments: segments, role: "thinking" }, index))] }), liveAnswer !== '' && _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { bold: true, color: PALETTE.accent, children: engine.state.activeModel }), liveAnswerView.map((segments, index) => _jsx(SegmentText, { segments: segments, role: "assistant" }, index))] }), toolCards.slice(-5).map((card) => {
|
|
899
937
|
const expanded = expandedTool === card.id;
|
|
900
938
|
const dot = card.status === 'running' ? _jsx(Text, { color: PALETTE.warn, children: ".." }) : card.status === 'error' ? _jsx(Text, { color: PALETTE.error, children: "FAIL" }) : _jsx(Text, { color: PALETTE.success, children: "ok" });
|
|
901
939
|
const head = card.name === 'run_command'
|