Compare commits
5 Commits
chore/bump
...
fix/config
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
116b91d2ae | ||
| 543388e18b | |||
| 07a1f5d594 | |||
|
|
c6fc090c98 | ||
| 9723b6b948 |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mosaic/gateway",
|
"name": "@mosaic/gateway",
|
||||||
"version": "0.1.0",
|
"version": "0.0.5",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.mosaicstack.dev/mosaic/mosaic-stack.git",
|
"url": "https://git.mosaicstack.dev/mosaic/mosaic-stack.git",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mosaic/cli",
|
"name": "@mosaic/cli",
|
||||||
"version": "0.0.15",
|
"version": "0.0.16",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.mosaicstack.dev/mosaic/mosaic-stack.git",
|
"url": "https://git.mosaicstack.dev/mosaic/mosaic-stack.git",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mosaic/config",
|
"name": "@mosaic/config",
|
||||||
"version": "0.0.1",
|
"version": "0.0.2",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.mosaicstack.dev/mosaic/mosaic-stack.git",
|
"url": "https://git.mosaicstack.dev/mosaic/mosaic-stack.git",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mosaic/mosaic",
|
"name": "@mosaic/mosaic",
|
||||||
"version": "0.0.15",
|
"version": "0.0.19",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.mosaicstack.dev/mosaic/mosaic-stack.git",
|
"url": "https://git.mosaicstack.dev/mosaic/mosaic-stack.git",
|
||||||
@@ -11,13 +11,16 @@
|
|||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
"mosaic": "dist/cli.js",
|
||||||
"mosaic-wizard": "dist/index.js"
|
"mosaic-wizard": "dist/index.js"
|
||||||
},
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"default": "./dist/index.js"
|
"default": "./dist/index.js"
|
||||||
}
|
},
|
||||||
|
"./package.json": "./package.json",
|
||||||
|
"./framework/*": "./framework/*"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
@@ -26,6 +29,7 @@
|
|||||||
"test": "vitest run --passWithNoTests"
|
"test": "vitest run --passWithNoTests"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@mosaic/config": "workspace:*",
|
||||||
"@mosaic/forge": "workspace:*",
|
"@mosaic/forge": "workspace:*",
|
||||||
"@mosaic/macp": "workspace:*",
|
"@mosaic/macp": "workspace:*",
|
||||||
"@mosaic/prdy": "workspace:*",
|
"@mosaic/prdy": "workspace:*",
|
||||||
@@ -33,12 +37,19 @@
|
|||||||
"@mosaic/types": "workspace:*",
|
"@mosaic/types": "workspace:*",
|
||||||
"@clack/prompts": "^0.9.1",
|
"@clack/prompts": "^0.9.1",
|
||||||
"commander": "^13.0.0",
|
"commander": "^13.0.0",
|
||||||
|
"ink": "^5.0.0",
|
||||||
|
"ink-spinner": "^5.0.0",
|
||||||
|
"ink-text-input": "^6.0.0",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
|
"react": "^18.3.0",
|
||||||
|
"socket.io-client": "^4.8.0",
|
||||||
"yaml": "^2.6.1",
|
"yaml": "^2.6.1",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
|
"@types/react": "^18.3.0",
|
||||||
|
"tsx": "^4.0.0",
|
||||||
"typescript": "^5.8.0",
|
"typescript": "^5.8.0",
|
||||||
"vitest": "^2.0.0"
|
"vitest": "^2.0.0"
|
||||||
},
|
},
|
||||||
|
|||||||
115
packages/mosaic/src/auth.ts
Normal file
115
packages/mosaic/src/auth.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
|
||||||
|
const SESSION_DIR = resolve(homedir(), '.mosaic');
|
||||||
|
const SESSION_FILE = resolve(SESSION_DIR, 'session.json');
|
||||||
|
|
||||||
|
interface StoredSession {
|
||||||
|
gatewayUrl: string;
|
||||||
|
cookie: string;
|
||||||
|
userId: string;
|
||||||
|
email: string;
|
||||||
|
expiresAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthResult {
|
||||||
|
cookie: string;
|
||||||
|
userId: string;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign in to the gateway and return the session cookie.
|
||||||
|
*/
|
||||||
|
export async function signIn(
|
||||||
|
gatewayUrl: string,
|
||||||
|
email: string,
|
||||||
|
password: string,
|
||||||
|
): Promise<AuthResult> {
|
||||||
|
const res = await fetch(`${gatewayUrl}/api/auth/sign-in/email`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Origin: gatewayUrl },
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
redirect: 'manual',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.text().catch(() => '');
|
||||||
|
throw new Error(`Sign-in failed (${res.status}): ${body}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract set-cookie header
|
||||||
|
const setCookieHeader = res.headers.getSetCookie?.() ?? [];
|
||||||
|
const sessionCookie = setCookieHeader
|
||||||
|
.map((c) => c.split(';')[0]!)
|
||||||
|
.filter((c) => c.startsWith('better-auth.session_token='))
|
||||||
|
.join('; ');
|
||||||
|
|
||||||
|
if (!sessionCookie) {
|
||||||
|
throw new Error('No session cookie returned from sign-in');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the response body for user info
|
||||||
|
const data = (await res.json()) as { user?: { id: string; email: string } };
|
||||||
|
const userId = data.user?.id ?? 'unknown';
|
||||||
|
const userEmail = data.user?.email ?? email;
|
||||||
|
|
||||||
|
return { cookie: sessionCookie, userId, email: userEmail };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save session to ~/.mosaic/session.json
|
||||||
|
*/
|
||||||
|
export function saveSession(gatewayUrl: string, auth: AuthResult): void {
|
||||||
|
if (!existsSync(SESSION_DIR)) {
|
||||||
|
mkdirSync(SESSION_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const session: StoredSession = {
|
||||||
|
gatewayUrl,
|
||||||
|
cookie: auth.cookie,
|
||||||
|
userId: auth.userId,
|
||||||
|
email: auth.email,
|
||||||
|
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), // 7 days
|
||||||
|
};
|
||||||
|
|
||||||
|
writeFileSync(SESSION_FILE, JSON.stringify(session, null, 2), 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load a saved session. Returns null if no session, expired, or wrong gateway.
|
||||||
|
*/
|
||||||
|
export function loadSession(gatewayUrl: string): AuthResult | null {
|
||||||
|
if (!existsSync(SESSION_FILE)) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = readFileSync(SESSION_FILE, 'utf-8');
|
||||||
|
const session = JSON.parse(raw) as StoredSession;
|
||||||
|
|
||||||
|
if (session.gatewayUrl !== gatewayUrl) return null;
|
||||||
|
if (new Date(session.expiresAt) < new Date()) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
cookie: session.cookie,
|
||||||
|
userId: session.userId,
|
||||||
|
email: session.email,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that a stored session is still active by hitting get-session.
|
||||||
|
*/
|
||||||
|
export async function validateSession(gatewayUrl: string, cookie: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${gatewayUrl}/api/auth/get-session`, {
|
||||||
|
headers: { Cookie: cookie, Origin: gatewayUrl },
|
||||||
|
});
|
||||||
|
return res.ok;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
425
packages/mosaic/src/cli.ts
Normal file
425
packages/mosaic/src/cli.ts
Normal file
@@ -0,0 +1,425 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { createRequire } from 'module';
|
||||||
|
import { Command } from 'commander';
|
||||||
|
import { registerQualityRails } from '@mosaic/quality-rails';
|
||||||
|
import { registerAgentCommand } from './commands/agent.js';
|
||||||
|
import { registerMissionCommand } from './commands/mission.js';
|
||||||
|
// prdy is registered via launch.ts
|
||||||
|
import { registerLaunchCommands } from './commands/launch.js';
|
||||||
|
import { registerGatewayCommand } from './commands/gateway.js';
|
||||||
|
import {
|
||||||
|
backgroundUpdateCheck,
|
||||||
|
checkForUpdate,
|
||||||
|
formatUpdateNotice,
|
||||||
|
} from './runtime/update-checker.js';
|
||||||
|
import { runWizard } from './wizard.js';
|
||||||
|
import { ClackPrompter } from './prompter/clack-prompter.js';
|
||||||
|
import { HeadlessPrompter } from './prompter/headless-prompter.js';
|
||||||
|
import { createConfigService } from './config/config-service.js';
|
||||||
|
import { WizardCancelledError } from './errors.js';
|
||||||
|
import { DEFAULT_MOSAIC_HOME } from './constants.js';
|
||||||
|
|
||||||
|
const _require = createRequire(import.meta.url);
|
||||||
|
const CLI_VERSION: string = (_require('../package.json') as { version: string }).version;
|
||||||
|
|
||||||
|
// Fire-and-forget update check at startup (non-blocking, cached 1h)
|
||||||
|
try {
|
||||||
|
backgroundUpdateCheck();
|
||||||
|
} catch {
|
||||||
|
// Silently ignore — update check is best-effort
|
||||||
|
}
|
||||||
|
|
||||||
|
const program = new Command();
|
||||||
|
|
||||||
|
program.name('mosaic').description('Mosaic Stack CLI').version(CLI_VERSION);
|
||||||
|
|
||||||
|
// ─── runtime launchers + framework commands ────────────────────────────
|
||||||
|
|
||||||
|
registerLaunchCommands(program);
|
||||||
|
|
||||||
|
// ─── login ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
program
|
||||||
|
.command('login')
|
||||||
|
.description('Sign in to a Mosaic gateway')
|
||||||
|
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
|
||||||
|
.option('-e, --email <email>', 'Email address')
|
||||||
|
.option('-p, --password <password>', 'Password')
|
||||||
|
.action(async (opts: { gateway: string; email?: string; password?: string }) => {
|
||||||
|
const { signIn, saveSession } = await import('./auth.js');
|
||||||
|
|
||||||
|
let email = opts.email;
|
||||||
|
let password = opts.password;
|
||||||
|
|
||||||
|
if (!email || !password) {
|
||||||
|
const readline = await import('node:readline');
|
||||||
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||||
|
const ask = (q: string): Promise<string> => new Promise((resolve) => rl.question(q, resolve));
|
||||||
|
|
||||||
|
if (!email) email = await ask('Email: ');
|
||||||
|
if (!password) password = await ask('Password: ');
|
||||||
|
rl.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const auth = await signIn(opts.gateway, email, password);
|
||||||
|
saveSession(opts.gateway, auth);
|
||||||
|
console.log(`Signed in as ${auth.email} (${opts.gateway})`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err instanceof Error ? err.message : String(err));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── tui ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
program
|
||||||
|
.command('tui')
|
||||||
|
.description('Launch interactive TUI connected to the gateway')
|
||||||
|
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
|
||||||
|
.option('-c, --conversation <id>', 'Resume a conversation by ID')
|
||||||
|
.option('-m, --model <modelId>', 'Model ID to use (e.g. gpt-4o, llama3.2)')
|
||||||
|
.option('-p, --provider <provider>', 'Provider to use (e.g. openai, ollama)')
|
||||||
|
.option('--agent <idOrName>', 'Connect to a specific agent')
|
||||||
|
.option('--project <idOrName>', 'Scope session to project')
|
||||||
|
.action(
|
||||||
|
async (opts: {
|
||||||
|
gateway: string;
|
||||||
|
conversation?: string;
|
||||||
|
model?: string;
|
||||||
|
provider?: string;
|
||||||
|
agent?: string;
|
||||||
|
project?: string;
|
||||||
|
}) => {
|
||||||
|
const { loadSession, validateSession, signIn, saveSession } = await import('./auth.js');
|
||||||
|
|
||||||
|
// Try loading saved session
|
||||||
|
let session = loadSession(opts.gateway);
|
||||||
|
|
||||||
|
if (session) {
|
||||||
|
const valid = await validateSession(opts.gateway, session.cookie);
|
||||||
|
if (!valid) {
|
||||||
|
console.log('Session expired. Please sign in again.');
|
||||||
|
session = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No valid session — prompt for credentials
|
||||||
|
if (!session) {
|
||||||
|
const readline = await import('node:readline');
|
||||||
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||||
|
const ask = (q: string): Promise<string> =>
|
||||||
|
new Promise((resolve) => rl.question(q, resolve));
|
||||||
|
|
||||||
|
console.log(`Sign in to ${opts.gateway}`);
|
||||||
|
const email = await ask('Email: ');
|
||||||
|
const password = await ask('Password: ');
|
||||||
|
rl.close();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const auth = await signIn(opts.gateway, email, password);
|
||||||
|
saveSession(opts.gateway, auth);
|
||||||
|
session = auth;
|
||||||
|
console.log(`Signed in as ${auth.email}\n`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err instanceof Error ? err.message : String(err));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve agent ID if --agent was passed by name
|
||||||
|
let agentId: string | undefined;
|
||||||
|
let agentName: string | undefined;
|
||||||
|
if (opts.agent) {
|
||||||
|
try {
|
||||||
|
const { fetchAgentConfigs } = await import('./tui/gateway-api.js');
|
||||||
|
const agents = await fetchAgentConfigs(opts.gateway, session.cookie);
|
||||||
|
const match = agents.find((a) => a.id === opts.agent || a.name === opts.agent);
|
||||||
|
if (match) {
|
||||||
|
agentId = match.id;
|
||||||
|
agentName = match.name;
|
||||||
|
} else {
|
||||||
|
console.error(`Agent "${opts.agent}" not found.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(
|
||||||
|
`Failed to resolve agent: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve project ID if --project was passed by name
|
||||||
|
let projectId: string | undefined;
|
||||||
|
if (opts.project) {
|
||||||
|
try {
|
||||||
|
const { fetchProjects } = await import('./tui/gateway-api.js');
|
||||||
|
const projects = await fetchProjects(opts.gateway, session.cookie);
|
||||||
|
const match = projects.find((p) => p.id === opts.project || p.name === opts.project);
|
||||||
|
if (match) {
|
||||||
|
projectId = match.id;
|
||||||
|
} else {
|
||||||
|
console.error(`Project "${opts.project}" not found.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(
|
||||||
|
`Failed to resolve project: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-create a conversation if none was specified
|
||||||
|
let conversationId = opts.conversation;
|
||||||
|
if (!conversationId) {
|
||||||
|
try {
|
||||||
|
const { createConversation } = await import('./tui/gateway-api.js');
|
||||||
|
const conv = await createConversation(opts.gateway, session.cookie, {
|
||||||
|
...(projectId ? { projectId } : {}),
|
||||||
|
});
|
||||||
|
conversationId = conv.id;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(
|
||||||
|
`Failed to create conversation: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dynamic import to avoid loading React/Ink for other commands
|
||||||
|
const { render } = await import('ink');
|
||||||
|
const React = await import('react');
|
||||||
|
const { TuiApp } = await import('./tui/app.js');
|
||||||
|
|
||||||
|
render(
|
||||||
|
React.createElement(TuiApp, {
|
||||||
|
gatewayUrl: opts.gateway,
|
||||||
|
conversationId,
|
||||||
|
sessionCookie: session.cookie,
|
||||||
|
initialModel: opts.model,
|
||||||
|
initialProvider: opts.provider,
|
||||||
|
agentId,
|
||||||
|
agentName: agentName ?? undefined,
|
||||||
|
projectId,
|
||||||
|
version: CLI_VERSION,
|
||||||
|
}),
|
||||||
|
{ exitOnCtrlC: false },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── sessions ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const sessionsCmd = program.command('sessions').description('Manage active agent sessions');
|
||||||
|
|
||||||
|
sessionsCmd
|
||||||
|
.command('list')
|
||||||
|
.description('List active agent sessions')
|
||||||
|
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
|
||||||
|
.action(async (opts: { gateway: string }) => {
|
||||||
|
const { withAuth } = await import('./commands/with-auth.js');
|
||||||
|
const auth = await withAuth(opts.gateway);
|
||||||
|
const { fetchSessions } = await import('./tui/gateway-api.js');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await fetchSessions(auth.gateway, auth.cookie);
|
||||||
|
if (result.total === 0) {
|
||||||
|
console.log('No active sessions.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(`Active sessions (${result.total}):\n`);
|
||||||
|
for (const s of result.sessions) {
|
||||||
|
const created = new Date(s.createdAt).toLocaleString();
|
||||||
|
const durationSec = Math.round(s.durationMs / 1000);
|
||||||
|
console.log(` ID: ${s.id}`);
|
||||||
|
console.log(` Model: ${s.provider}/${s.modelId}`);
|
||||||
|
console.log(` Created: ${created}`);
|
||||||
|
console.log(` Prompts: ${s.promptCount}`);
|
||||||
|
console.log(` Duration: ${durationSec}s`);
|
||||||
|
if (s.channels.length > 0) {
|
||||||
|
console.log(` Channels: ${s.channels.join(', ')}`);
|
||||||
|
}
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err instanceof Error ? err.message : String(err));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
sessionsCmd
|
||||||
|
.command('resume <id>')
|
||||||
|
.description('Resume an existing agent session in the TUI')
|
||||||
|
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
|
||||||
|
.action(async (id: string, opts: { gateway: string }) => {
|
||||||
|
const { loadSession, validateSession } = await import('./auth.js');
|
||||||
|
|
||||||
|
const session = loadSession(opts.gateway);
|
||||||
|
if (!session) {
|
||||||
|
console.error('Not signed in. Run `mosaic login` first.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid = await validateSession(opts.gateway, session.cookie);
|
||||||
|
if (!valid) {
|
||||||
|
console.error('Session expired. Run `mosaic login` again.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { render } = await import('ink');
|
||||||
|
const React = await import('react');
|
||||||
|
const { TuiApp } = await import('./tui/app.js');
|
||||||
|
|
||||||
|
render(
|
||||||
|
React.createElement(TuiApp, {
|
||||||
|
gatewayUrl: opts.gateway,
|
||||||
|
conversationId: id,
|
||||||
|
sessionCookie: session.cookie,
|
||||||
|
version: CLI_VERSION,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
sessionsCmd
|
||||||
|
.command('destroy <id>')
|
||||||
|
.description('Terminate an active agent session')
|
||||||
|
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
|
||||||
|
.action(async (id: string, opts: { gateway: string }) => {
|
||||||
|
const { withAuth } = await import('./commands/with-auth.js');
|
||||||
|
const auth = await withAuth(opts.gateway);
|
||||||
|
const { deleteSession } = await import('./tui/gateway-api.js');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteSession(auth.gateway, auth.cookie, id);
|
||||||
|
console.log(`Session ${id} destroyed.`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err instanceof Error ? err.message : String(err));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── gateway ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
registerGatewayCommand(program);
|
||||||
|
|
||||||
|
// ─── agent ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
registerAgentCommand(program);
|
||||||
|
|
||||||
|
// ─── mission ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
registerMissionCommand(program);
|
||||||
|
|
||||||
|
// ─── quality-rails ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
registerQualityRails(program);
|
||||||
|
|
||||||
|
// ─── update ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
program
|
||||||
|
.command('update')
|
||||||
|
.description('Check for and install Mosaic CLI updates')
|
||||||
|
.option('--check', 'Check only, do not install')
|
||||||
|
.action(async (opts: { check?: boolean }) => {
|
||||||
|
// checkForUpdate and formatUpdateNotice imported statically above
|
||||||
|
const { execSync } = await import('node:child_process');
|
||||||
|
|
||||||
|
console.log('Checking for updates…');
|
||||||
|
const result = checkForUpdate({ skipCache: true });
|
||||||
|
|
||||||
|
if (!result.latest) {
|
||||||
|
console.error('Could not reach the Mosaic registry.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` Installed: ${result.current || '(none)'}`);
|
||||||
|
console.log(` Latest: ${result.latest}`);
|
||||||
|
|
||||||
|
if (!result.updateAvailable) {
|
||||||
|
console.log('\n✔ Up to date.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const notice = formatUpdateNotice(result);
|
||||||
|
if (notice) console.log(notice);
|
||||||
|
|
||||||
|
if (opts.check) {
|
||||||
|
process.exit(2); // Signal to callers that an update exists
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Installing update…');
|
||||||
|
try {
|
||||||
|
// Relies on @mosaic:registry in ~/.npmrc — do NOT pass --registry
|
||||||
|
// globally or non-@mosaic deps will 404 against the Gitea registry.
|
||||||
|
execSync('npm install -g @mosaic/cli@latest', {
|
||||||
|
stdio: 'inherit',
|
||||||
|
timeout: 60_000,
|
||||||
|
});
|
||||||
|
console.log('\n✔ Updated successfully.');
|
||||||
|
} catch {
|
||||||
|
console.error('\nUpdate failed. Try manually: bash tools/install.sh');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── wizard ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
program
|
||||||
|
.command('wizard')
|
||||||
|
.description('Run the Mosaic installation wizard')
|
||||||
|
.option('--non-interactive', 'Run without prompts (uses defaults + flags)')
|
||||||
|
.option('--source-dir <path>', 'Source directory for framework files')
|
||||||
|
.option('--mosaic-home <path>', 'Target config directory')
|
||||||
|
.option('--name <name>', 'Agent name')
|
||||||
|
.option('--role <description>', 'Agent role description')
|
||||||
|
.option('--style <style>', 'Communication style: direct|friendly|formal')
|
||||||
|
.option('--accessibility <prefs>', 'Accessibility preferences')
|
||||||
|
.option('--guardrails <rules>', 'Custom guardrails')
|
||||||
|
.option('--user-name <name>', 'Your name')
|
||||||
|
.option('--pronouns <pronouns>', 'Your pronouns')
|
||||||
|
.option('--timezone <tz>', 'Your timezone')
|
||||||
|
.action(async (opts: Record<string, string | boolean | undefined>) => {
|
||||||
|
// All wizard imports are now static (see top of file)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const mosaicHome = (opts['mosaicHome'] as string | undefined) ?? DEFAULT_MOSAIC_HOME;
|
||||||
|
const sourceDir = (opts['sourceDir'] as string | undefined) ?? mosaicHome;
|
||||||
|
|
||||||
|
const prompter = opts['nonInteractive'] ? new HeadlessPrompter() : new ClackPrompter();
|
||||||
|
|
||||||
|
const configService = createConfigService(mosaicHome, sourceDir);
|
||||||
|
|
||||||
|
await runWizard({
|
||||||
|
mosaicHome,
|
||||||
|
sourceDir,
|
||||||
|
prompter,
|
||||||
|
configService,
|
||||||
|
cliOverrides: {
|
||||||
|
soul: {
|
||||||
|
agentName: opts['name'] as string | undefined,
|
||||||
|
roleDescription: opts['role'] as string | undefined,
|
||||||
|
communicationStyle: opts['style'] as 'direct' | 'friendly' | 'formal' | undefined,
|
||||||
|
accessibility: opts['accessibility'] as string | undefined,
|
||||||
|
customGuardrails: opts['guardrails'] as string | undefined,
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
userName: opts['userName'] as string | undefined,
|
||||||
|
pronouns: opts['pronouns'] as string | undefined,
|
||||||
|
timezone: opts['timezone'] as string | undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof WizardCancelledError) {
|
||||||
|
console.log('\nWizard cancelled.');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
console.error('Wizard failed:', err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
program.parse();
|
||||||
241
packages/mosaic/src/commands/agent.ts
Normal file
241
packages/mosaic/src/commands/agent.ts
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
import type { Command } from 'commander';
|
||||||
|
import { withAuth } from './with-auth.js';
|
||||||
|
import { selectItem } from './select-dialog.js';
|
||||||
|
import {
|
||||||
|
fetchAgentConfigs,
|
||||||
|
createAgentConfig,
|
||||||
|
updateAgentConfig,
|
||||||
|
deleteAgentConfig,
|
||||||
|
fetchProjects,
|
||||||
|
fetchProviders,
|
||||||
|
} from '../tui/gateway-api.js';
|
||||||
|
import type { AgentConfigInfo } from '../tui/gateway-api.js';
|
||||||
|
|
||||||
|
function formatAgent(a: AgentConfigInfo): string {
|
||||||
|
const sys = a.isSystem ? ' [system]' : '';
|
||||||
|
return `${a.name}${sys} — ${a.provider}/${a.model} (${a.status})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showAgentDetail(a: AgentConfigInfo) {
|
||||||
|
console.log(` ID: ${a.id}`);
|
||||||
|
console.log(` Name: ${a.name}`);
|
||||||
|
console.log(` Provider: ${a.provider}`);
|
||||||
|
console.log(` Model: ${a.model}`);
|
||||||
|
console.log(` Status: ${a.status}`);
|
||||||
|
console.log(` System: ${a.isSystem ? 'yes' : 'no'}`);
|
||||||
|
console.log(` Project: ${a.projectId ?? '—'}`);
|
||||||
|
console.log(` System Prompt: ${a.systemPrompt ? `${a.systemPrompt.slice(0, 80)}...` : '—'}`);
|
||||||
|
console.log(` Tools: ${a.allowedTools ? a.allowedTools.join(', ') : 'all'}`);
|
||||||
|
console.log(` Skills: ${a.skills ? a.skills.join(', ') : '—'}`);
|
||||||
|
console.log(` Created: ${new Date(a.createdAt).toLocaleString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerAgentCommand(program: Command) {
|
||||||
|
const cmd = program
|
||||||
|
.command('agent')
|
||||||
|
.description('Manage agent configurations')
|
||||||
|
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
|
||||||
|
.option('--list', 'List all agents')
|
||||||
|
.option('--new', 'Create a new agent')
|
||||||
|
.option('--show <idOrName>', 'Show agent details')
|
||||||
|
.option('--update <idOrName>', 'Update an agent')
|
||||||
|
.option('--delete <idOrName>', 'Delete an agent')
|
||||||
|
.action(
|
||||||
|
async (opts: {
|
||||||
|
gateway: string;
|
||||||
|
list?: boolean;
|
||||||
|
new?: boolean;
|
||||||
|
show?: string;
|
||||||
|
update?: string;
|
||||||
|
delete?: string;
|
||||||
|
}) => {
|
||||||
|
const auth = await withAuth(opts.gateway);
|
||||||
|
|
||||||
|
if (opts.list) {
|
||||||
|
return listAgents(auth.gateway, auth.cookie);
|
||||||
|
}
|
||||||
|
if (opts.new) {
|
||||||
|
return createAgentWizard(auth.gateway, auth.cookie);
|
||||||
|
}
|
||||||
|
if (opts.show) {
|
||||||
|
return showAgent(auth.gateway, auth.cookie, opts.show);
|
||||||
|
}
|
||||||
|
if (opts.update) {
|
||||||
|
return updateAgentWizard(auth.gateway, auth.cookie, opts.update);
|
||||||
|
}
|
||||||
|
if (opts.delete) {
|
||||||
|
return deleteAgent(auth.gateway, auth.cookie, opts.delete);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: interactive select
|
||||||
|
return interactiveSelect(auth.gateway, auth.cookie);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return cmd;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveAgent(
|
||||||
|
gateway: string,
|
||||||
|
cookie: string,
|
||||||
|
idOrName: string,
|
||||||
|
): Promise<AgentConfigInfo | undefined> {
|
||||||
|
const agents = await fetchAgentConfigs(gateway, cookie);
|
||||||
|
return agents.find((a) => a.id === idOrName || a.name === idOrName);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listAgents(gateway: string, cookie: string) {
|
||||||
|
const agents = await fetchAgentConfigs(gateway, cookie);
|
||||||
|
if (agents.length === 0) {
|
||||||
|
console.log('No agents found.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(`Agents (${agents.length}):\n`);
|
||||||
|
for (const a of agents) {
|
||||||
|
const sys = a.isSystem ? ' [system]' : '';
|
||||||
|
const project = a.projectId ? ` project=${a.projectId.slice(0, 8)}` : '';
|
||||||
|
console.log(` ${a.name}${sys} ${a.provider}/${a.model} ${a.status}${project}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showAgent(gateway: string, cookie: string, idOrName: string) {
|
||||||
|
const agent = await resolveAgent(gateway, cookie, idOrName);
|
||||||
|
if (!agent) {
|
||||||
|
console.error(`Agent "${idOrName}" not found.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
showAgentDetail(agent);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function interactiveSelect(gateway: string, cookie: string) {
|
||||||
|
const agents = await fetchAgentConfigs(gateway, cookie);
|
||||||
|
const selected = await selectItem(agents, {
|
||||||
|
message: 'Select an agent:',
|
||||||
|
render: formatAgent,
|
||||||
|
emptyMessage: 'No agents found. Create one with `mosaic agent --new`.',
|
||||||
|
});
|
||||||
|
if (selected) {
|
||||||
|
showAgentDetail(selected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createAgentWizard(gateway: string, cookie: string) {
|
||||||
|
const readline = await import('node:readline');
|
||||||
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||||
|
const ask = (q: string): Promise<string> => new Promise((resolve) => rl.question(q, resolve));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const name = await ask('Agent name: ');
|
||||||
|
if (!name.trim()) {
|
||||||
|
console.error('Name is required.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Project selection
|
||||||
|
const projects = await fetchProjects(gateway, cookie);
|
||||||
|
let projectId: string | undefined;
|
||||||
|
if (projects.length > 0) {
|
||||||
|
const selected = await selectItem(projects, {
|
||||||
|
message: 'Assign to project (optional):',
|
||||||
|
render: (p) => `${p.name} (${p.status})`,
|
||||||
|
});
|
||||||
|
if (selected) projectId = selected.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provider / model selection
|
||||||
|
const providers = await fetchProviders(gateway, cookie);
|
||||||
|
let provider = 'default';
|
||||||
|
let model = 'default';
|
||||||
|
|
||||||
|
if (providers.length > 0) {
|
||||||
|
const allModels = providers.flatMap((p) =>
|
||||||
|
p.models.map((m) => ({ provider: p.name, model: m.id, label: `${p.name}/${m.id}` })),
|
||||||
|
);
|
||||||
|
if (allModels.length > 0) {
|
||||||
|
const selected = await selectItem(allModels, {
|
||||||
|
message: 'Select model:',
|
||||||
|
render: (m) => m.label,
|
||||||
|
});
|
||||||
|
if (selected) {
|
||||||
|
provider = selected.provider;
|
||||||
|
model = selected.model;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const systemPrompt = await ask('System prompt (optional, press Enter to skip): ');
|
||||||
|
|
||||||
|
const agent = await createAgentConfig(gateway, cookie, {
|
||||||
|
name: name.trim(),
|
||||||
|
provider,
|
||||||
|
model,
|
||||||
|
projectId,
|
||||||
|
systemPrompt: systemPrompt.trim() || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`\nAgent "${agent.name}" created (${agent.id}).`);
|
||||||
|
} finally {
|
||||||
|
rl.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateAgentWizard(gateway: string, cookie: string, idOrName: string) {
|
||||||
|
const agent = await resolveAgent(gateway, cookie, idOrName);
|
||||||
|
if (!agent) {
|
||||||
|
console.error(`Agent "${idOrName}" not found.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const readline = await import('node:readline');
|
||||||
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||||
|
const ask = (q: string): Promise<string> => new Promise((resolve) => rl.question(q, resolve));
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`Updating agent: ${agent.name}\n`);
|
||||||
|
|
||||||
|
const name = await ask(`Name [${agent.name}]: `);
|
||||||
|
const systemPrompt = await ask(`System prompt [${agent.systemPrompt ? 'set' : 'none'}]: `);
|
||||||
|
|
||||||
|
const updates: Record<string, unknown> = {};
|
||||||
|
if (name.trim()) updates['name'] = name.trim();
|
||||||
|
if (systemPrompt.trim()) updates['systemPrompt'] = systemPrompt.trim();
|
||||||
|
|
||||||
|
if (Object.keys(updates).length === 0) {
|
||||||
|
console.log('No changes.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await updateAgentConfig(gateway, cookie, agent.id, updates);
|
||||||
|
console.log(`\nAgent "${updated.name}" updated.`);
|
||||||
|
} finally {
|
||||||
|
rl.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteAgent(gateway: string, cookie: string, idOrName: string) {
|
||||||
|
const agent = await resolveAgent(gateway, cookie, idOrName);
|
||||||
|
if (!agent) {
|
||||||
|
console.error(`Agent "${idOrName}" not found.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (agent.isSystem) {
|
||||||
|
console.error('Cannot delete system agents.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const readline = await import('node:readline');
|
||||||
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||||
|
const answer = await new Promise<string>((resolve) =>
|
||||||
|
rl.question(`Delete agent "${agent.name}"? (y/N): `, resolve),
|
||||||
|
);
|
||||||
|
rl.close();
|
||||||
|
|
||||||
|
if (answer.toLowerCase() !== 'y') {
|
||||||
|
console.log('Cancelled.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteAgentConfig(gateway, cookie, agent.id);
|
||||||
|
console.log(`Agent "${agent.name}" deleted.`);
|
||||||
|
}
|
||||||
152
packages/mosaic/src/commands/gateway.ts
Normal file
152
packages/mosaic/src/commands/gateway.ts
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import type { Command } from 'commander';
|
||||||
|
import {
|
||||||
|
getDaemonPid,
|
||||||
|
readMeta,
|
||||||
|
startDaemon,
|
||||||
|
stopDaemon,
|
||||||
|
waitForHealth,
|
||||||
|
} from './gateway/daemon.js';
|
||||||
|
|
||||||
|
interface GatewayParentOpts {
|
||||||
|
host: string;
|
||||||
|
port: string;
|
||||||
|
token?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveOpts(raw: GatewayParentOpts): { host: string; port: number; token?: string } {
|
||||||
|
const meta = readMeta();
|
||||||
|
return {
|
||||||
|
host: raw.host ?? meta?.host ?? 'localhost',
|
||||||
|
port: parseInt(raw.port, 10) || meta?.port || 14242,
|
||||||
|
token: raw.token ?? meta?.adminToken,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerGatewayCommand(program: Command): void {
|
||||||
|
const gw = program
|
||||||
|
.command('gateway')
|
||||||
|
.description('Manage the Mosaic gateway daemon')
|
||||||
|
.helpOption('--help', 'Display help')
|
||||||
|
.option('-h, --host <host>', 'Gateway host', 'localhost')
|
||||||
|
.option('-p, --port <port>', 'Gateway port', '14242')
|
||||||
|
.option('-t, --token <token>', 'Admin API token')
|
||||||
|
.action(() => {
|
||||||
|
gw.outputHelp();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── install ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
gw.command('install')
|
||||||
|
.description('Install and configure the gateway daemon')
|
||||||
|
.option('--skip-install', 'Skip npm package installation (use local build)')
|
||||||
|
.action(async (cmdOpts: { skipInstall?: boolean }) => {
|
||||||
|
const opts = resolveOpts(gw.opts() as GatewayParentOpts);
|
||||||
|
const { runInstall } = await import('./gateway/install.js');
|
||||||
|
await runInstall({ ...opts, skipInstall: cmdOpts.skipInstall });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── start ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
gw.command('start')
|
||||||
|
.description('Start the gateway daemon')
|
||||||
|
.action(async () => {
|
||||||
|
const opts = resolveOpts(gw.opts() as GatewayParentOpts);
|
||||||
|
try {
|
||||||
|
const pid = startDaemon();
|
||||||
|
console.log(`Gateway started (PID ${pid.toString()})`);
|
||||||
|
console.log('Waiting for health...');
|
||||||
|
const healthy = await waitForHealth(opts.host, opts.port);
|
||||||
|
if (healthy) {
|
||||||
|
console.log(`Gateway ready at http://${opts.host}:${opts.port.toString()}`);
|
||||||
|
} else {
|
||||||
|
console.warn('Gateway started but health check timed out. Check logs.');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err instanceof Error ? err.message : String(err));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── stop ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
gw.command('stop')
|
||||||
|
.description('Stop the gateway daemon')
|
||||||
|
.action(async () => {
|
||||||
|
try {
|
||||||
|
await stopDaemon();
|
||||||
|
console.log('Gateway stopped.');
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err instanceof Error ? err.message : String(err));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── restart ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
gw.command('restart')
|
||||||
|
.description('Restart the gateway daemon')
|
||||||
|
.action(async () => {
|
||||||
|
const opts = resolveOpts(gw.opts() as GatewayParentOpts);
|
||||||
|
const pid = getDaemonPid();
|
||||||
|
if (pid !== null) {
|
||||||
|
console.log('Stopping gateway...');
|
||||||
|
await stopDaemon();
|
||||||
|
}
|
||||||
|
console.log('Starting gateway...');
|
||||||
|
try {
|
||||||
|
const newPid = startDaemon();
|
||||||
|
console.log(`Gateway started (PID ${newPid.toString()})`);
|
||||||
|
const healthy = await waitForHealth(opts.host, opts.port);
|
||||||
|
if (healthy) {
|
||||||
|
console.log(`Gateway ready at http://${opts.host}:${opts.port.toString()}`);
|
||||||
|
} else {
|
||||||
|
console.warn('Gateway started but health check timed out. Check logs.');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err instanceof Error ? err.message : String(err));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── status ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
gw.command('status')
|
||||||
|
.description('Show gateway daemon status and health')
|
||||||
|
.action(async () => {
|
||||||
|
const opts = resolveOpts(gw.opts() as GatewayParentOpts);
|
||||||
|
const { runStatus } = await import('./gateway/status.js');
|
||||||
|
await runStatus(opts);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── config ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
gw.command('config')
|
||||||
|
.description('View or modify gateway configuration')
|
||||||
|
.option('--set <KEY=VALUE>', 'Set a configuration value')
|
||||||
|
.option('--unset <KEY>', 'Remove a configuration key')
|
||||||
|
.option('--edit', 'Open config in $EDITOR')
|
||||||
|
.action(async (cmdOpts: { set?: string; unset?: string; edit?: boolean }) => {
|
||||||
|
const { runConfig } = await import('./gateway/config.js');
|
||||||
|
await runConfig(cmdOpts);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── logs ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
gw.command('logs')
|
||||||
|
.description('View gateway daemon logs')
|
||||||
|
.option('-f, --follow', 'Follow log output')
|
||||||
|
.option('-n, --lines <count>', 'Number of lines to show', '50')
|
||||||
|
.action(async (cmdOpts: { follow?: boolean; lines?: string }) => {
|
||||||
|
const { runLogs } = await import('./gateway/logs.js');
|
||||||
|
runLogs({ follow: cmdOpts.follow, lines: parseInt(cmdOpts.lines ?? '50', 10) });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── uninstall ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
gw.command('uninstall')
|
||||||
|
.description('Uninstall the gateway daemon and optionally remove data')
|
||||||
|
.action(async () => {
|
||||||
|
const { runUninstall } = await import('./gateway/uninstall.js');
|
||||||
|
await runUninstall();
|
||||||
|
});
|
||||||
|
}
|
||||||
143
packages/mosaic/src/commands/gateway/config.ts
Normal file
143
packages/mosaic/src/commands/gateway/config.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { execSync } from 'node:child_process';
|
||||||
|
import { ENV_FILE, getDaemonPid, readMeta, META_FILE, ensureDirs } from './daemon.js';
|
||||||
|
|
||||||
|
// Keys that should be masked in output
|
||||||
|
const SECRET_KEYS = new Set([
|
||||||
|
'BETTER_AUTH_SECRET',
|
||||||
|
'ANTHROPIC_API_KEY',
|
||||||
|
'OPENAI_API_KEY',
|
||||||
|
'ZAI_API_KEY',
|
||||||
|
'OPENROUTER_API_KEY',
|
||||||
|
'DISCORD_BOT_TOKEN',
|
||||||
|
'TELEGRAM_BOT_TOKEN',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function maskValue(key: string, value: string): string {
|
||||||
|
if (SECRET_KEYS.has(key) && value.length > 8) {
|
||||||
|
return value.slice(0, 4) + '…' + value.slice(-4);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEnvFile(): Map<string, string> {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
if (!existsSync(ENV_FILE)) return map;
|
||||||
|
|
||||||
|
const lines = readFileSync(ENV_FILE, 'utf-8').split('\n');
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||||
|
const eqIdx = trimmed.indexOf('=');
|
||||||
|
if (eqIdx === -1) continue;
|
||||||
|
map.set(trimmed.slice(0, eqIdx), trimmed.slice(eqIdx + 1));
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeEnvFile(entries: Map<string, string>): void {
|
||||||
|
ensureDirs();
|
||||||
|
const lines: string[] = [];
|
||||||
|
for (const [key, value] of entries) {
|
||||||
|
lines.push(`${key}=${value}`);
|
||||||
|
}
|
||||||
|
writeFileSync(ENV_FILE, lines.join('\n') + '\n', { mode: 0o600 });
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfigOpts {
|
||||||
|
set?: string;
|
||||||
|
unset?: string;
|
||||||
|
edit?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runConfig(opts: ConfigOpts): Promise<void> {
|
||||||
|
// Set a value
|
||||||
|
if (opts.set) {
|
||||||
|
const eqIdx = opts.set.indexOf('=');
|
||||||
|
if (eqIdx === -1) {
|
||||||
|
console.error('Usage: mosaic gateway config --set KEY=VALUE');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const key = opts.set.slice(0, eqIdx);
|
||||||
|
const value = opts.set.slice(eqIdx + 1);
|
||||||
|
const entries = parseEnvFile();
|
||||||
|
entries.set(key, value);
|
||||||
|
writeEnvFile(entries);
|
||||||
|
console.log(`Set ${key}=${maskValue(key, value)}`);
|
||||||
|
promptRestart();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unset a value
|
||||||
|
if (opts.unset) {
|
||||||
|
const entries = parseEnvFile();
|
||||||
|
if (!entries.has(opts.unset)) {
|
||||||
|
console.error(`Key not found: ${opts.unset}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
entries.delete(opts.unset);
|
||||||
|
writeEnvFile(entries);
|
||||||
|
console.log(`Removed ${opts.unset}`);
|
||||||
|
promptRestart();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open in editor
|
||||||
|
if (opts.edit) {
|
||||||
|
if (!existsSync(ENV_FILE)) {
|
||||||
|
console.error(`No config file found at ${ENV_FILE}`);
|
||||||
|
console.error('Run `mosaic gateway install` first.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const editor = process.env['EDITOR'] ?? process.env['VISUAL'] ?? 'vi';
|
||||||
|
try {
|
||||||
|
execSync(`${editor} "${ENV_FILE}"`, { stdio: 'inherit' });
|
||||||
|
promptRestart();
|
||||||
|
} catch {
|
||||||
|
console.error('Editor exited with error.');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: show current config
|
||||||
|
showConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showConfig(): void {
|
||||||
|
if (!existsSync(ENV_FILE)) {
|
||||||
|
console.log('No gateway configuration found.');
|
||||||
|
console.log('Run `mosaic gateway install` to set up.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = parseEnvFile();
|
||||||
|
const meta = readMeta();
|
||||||
|
|
||||||
|
console.log('Mosaic Gateway Configuration');
|
||||||
|
console.log('────────────────────────────');
|
||||||
|
console.log(` Config file: ${ENV_FILE}`);
|
||||||
|
console.log(` Meta file: ${META_FILE}`);
|
||||||
|
console.log();
|
||||||
|
|
||||||
|
if (entries.size === 0) {
|
||||||
|
console.log(' (empty)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxKeyLen = Math.max(...[...entries.keys()].map((k) => k.length));
|
||||||
|
for (const [key, value] of entries) {
|
||||||
|
const padding = ' '.repeat(maxKeyLen - key.length);
|
||||||
|
console.log(` ${key}${padding} ${maskValue(key, value)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (meta?.adminToken) {
|
||||||
|
console.log();
|
||||||
|
console.log(` Admin token: ${maskValue('token', meta.adminToken)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function promptRestart(): void {
|
||||||
|
if (getDaemonPid() !== null) {
|
||||||
|
console.log('\nGateway is running — restart to apply changes: mosaic gateway restart');
|
||||||
|
}
|
||||||
|
}
|
||||||
245
packages/mosaic/src/commands/gateway/daemon.ts
Normal file
245
packages/mosaic/src/commands/gateway/daemon.ts
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
import { spawn, execSync } from 'node:child_process';
|
||||||
|
import {
|
||||||
|
existsSync,
|
||||||
|
mkdirSync,
|
||||||
|
readFileSync,
|
||||||
|
writeFileSync,
|
||||||
|
unlinkSync,
|
||||||
|
openSync,
|
||||||
|
constants,
|
||||||
|
} from 'node:fs';
|
||||||
|
import { join, resolve } from 'node:path';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
|
|
||||||
|
// ─── Paths ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const GATEWAY_HOME = resolve(
|
||||||
|
process.env['MOSAIC_GATEWAY_HOME'] ?? join(homedir(), '.config', 'mosaic', 'gateway'),
|
||||||
|
);
|
||||||
|
export const PID_FILE = join(GATEWAY_HOME, 'daemon.pid');
|
||||||
|
export const LOG_DIR = join(GATEWAY_HOME, 'logs');
|
||||||
|
export const LOG_FILE = join(LOG_DIR, 'gateway.log');
|
||||||
|
export const ENV_FILE = join(GATEWAY_HOME, '.env');
|
||||||
|
export const META_FILE = join(GATEWAY_HOME, 'meta.json');
|
||||||
|
|
||||||
|
// ─── Meta ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface GatewayMeta {
|
||||||
|
version: string;
|
||||||
|
installedAt: string;
|
||||||
|
entryPoint: string;
|
||||||
|
adminToken?: string;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readMeta(): GatewayMeta | null {
|
||||||
|
if (!existsSync(META_FILE)) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(readFileSync(META_FILE, 'utf-8')) as GatewayMeta;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeMeta(meta: GatewayMeta): void {
|
||||||
|
ensureDirs();
|
||||||
|
writeFileSync(META_FILE, JSON.stringify(meta, null, 2), { mode: 0o600 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Directories ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function ensureDirs(): void {
|
||||||
|
mkdirSync(GATEWAY_HOME, { recursive: true, mode: 0o700 });
|
||||||
|
mkdirSync(LOG_DIR, { recursive: true, mode: 0o700 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── PID management ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function readPid(): number | null {
|
||||||
|
if (!existsSync(PID_FILE)) return null;
|
||||||
|
try {
|
||||||
|
const pid = parseInt(readFileSync(PID_FILE, 'utf-8').trim(), 10);
|
||||||
|
return Number.isNaN(pid) ? null : pid;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRunning(pid: number): boolean {
|
||||||
|
try {
|
||||||
|
process.kill(pid, 0);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDaemonPid(): number | null {
|
||||||
|
const pid = readPid();
|
||||||
|
if (pid === null) return null;
|
||||||
|
return isRunning(pid) ? pid : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Entry point resolution ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function resolveGatewayEntry(): string {
|
||||||
|
// Check meta.json for custom entry point
|
||||||
|
const meta = readMeta();
|
||||||
|
if (meta?.entryPoint && existsSync(meta.entryPoint)) {
|
||||||
|
return meta.entryPoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to resolve from globally installed @mosaic/gateway
|
||||||
|
try {
|
||||||
|
const req = createRequire(import.meta.url);
|
||||||
|
const pkgPath = req.resolve('@mosaic/gateway/package.json');
|
||||||
|
const mainEntry = join(resolve(pkgPath, '..'), 'dist', 'main.js');
|
||||||
|
if (existsSync(mainEntry)) return mainEntry;
|
||||||
|
} catch {
|
||||||
|
// Not installed globally
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Cannot find gateway entry point. Run `mosaic gateway install` first.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Start / Stop / Health ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function startDaemon(): number {
|
||||||
|
const running = getDaemonPid();
|
||||||
|
if (running !== null) {
|
||||||
|
throw new Error(`Gateway is already running (PID ${running.toString()})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureDirs();
|
||||||
|
const entryPoint = resolveGatewayEntry();
|
||||||
|
|
||||||
|
// Load env vars from gateway .env
|
||||||
|
const env: Record<string, string> = { ...process.env } as Record<string, string>;
|
||||||
|
if (existsSync(ENV_FILE)) {
|
||||||
|
for (const line of readFileSync(ENV_FILE, 'utf-8').split('\n')) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||||
|
const eqIdx = trimmed.indexOf('=');
|
||||||
|
if (eqIdx > 0) env[trimmed.slice(0, eqIdx)] = trimmed.slice(eqIdx + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const logFd = openSync(LOG_FILE, constants.O_WRONLY | constants.O_CREAT | constants.O_APPEND);
|
||||||
|
|
||||||
|
const child = spawn('node', [entryPoint], {
|
||||||
|
detached: true,
|
||||||
|
stdio: ['ignore', logFd, logFd],
|
||||||
|
env,
|
||||||
|
cwd: GATEWAY_HOME,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!child.pid) {
|
||||||
|
throw new Error('Failed to spawn gateway process');
|
||||||
|
}
|
||||||
|
|
||||||
|
writeFileSync(PID_FILE, child.pid.toString(), { mode: 0o600 });
|
||||||
|
child.unref();
|
||||||
|
|
||||||
|
return child.pid;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stopDaemon(timeoutMs = 10_000): Promise<void> {
|
||||||
|
const pid = getDaemonPid();
|
||||||
|
if (pid === null) {
|
||||||
|
throw new Error('Gateway is not running');
|
||||||
|
}
|
||||||
|
|
||||||
|
process.kill(pid, 'SIGTERM');
|
||||||
|
|
||||||
|
// Poll for exit
|
||||||
|
const start = Date.now();
|
||||||
|
while (Date.now() - start < timeoutMs) {
|
||||||
|
if (!isRunning(pid)) {
|
||||||
|
cleanPidFile();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await sleep(250);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force kill
|
||||||
|
try {
|
||||||
|
process.kill(pid, 'SIGKILL');
|
||||||
|
} catch {
|
||||||
|
// Already dead
|
||||||
|
}
|
||||||
|
cleanPidFile();
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanPidFile(): void {
|
||||||
|
try {
|
||||||
|
unlinkSync(PID_FILE);
|
||||||
|
} catch {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitForHealth(
|
||||||
|
host: string,
|
||||||
|
port: number,
|
||||||
|
timeoutMs = 30_000,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const start = Date.now();
|
||||||
|
let delay = 500;
|
||||||
|
|
||||||
|
while (Date.now() - start < timeoutMs) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`http://${host}:${port.toString()}/health`);
|
||||||
|
if (res.ok) return true;
|
||||||
|
} catch {
|
||||||
|
// Not ready yet
|
||||||
|
}
|
||||||
|
await sleep(delay);
|
||||||
|
delay = Math.min(delay * 1.5, 3000);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── npm install helper ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const GITEA_REGISTRY = 'https://git.mosaicstack.dev/api/packages/mosaic/npm/';
|
||||||
|
|
||||||
|
export function installGatewayPackage(): void {
|
||||||
|
console.log('Installing @mosaic/gateway from Gitea registry...');
|
||||||
|
execSync(`npm install -g @mosaic/gateway@latest --@mosaic:registry=${GITEA_REGISTRY}`, {
|
||||||
|
stdio: 'inherit',
|
||||||
|
timeout: 120_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uninstallGatewayPackage(): void {
|
||||||
|
try {
|
||||||
|
execSync('npm uninstall -g @mosaic/gateway', {
|
||||||
|
stdio: 'inherit',
|
||||||
|
timeout: 60_000,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
console.warn('Warning: npm uninstall may not have completed cleanly.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getInstalledGatewayVersion(): string | null {
|
||||||
|
try {
|
||||||
|
const output = execSync('npm ls -g @mosaic/gateway --json --depth=0', {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
timeout: 15_000,
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
const data = JSON.parse(output) as {
|
||||||
|
dependencies?: { '@mosaic/gateway'?: { version?: string } };
|
||||||
|
};
|
||||||
|
return data.dependencies?.['@mosaic/gateway']?.version ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
259
packages/mosaic/src/commands/gateway/install.ts
Normal file
259
packages/mosaic/src/commands/gateway/install.ts
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
import { writeFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { createInterface } from 'node:readline';
|
||||||
|
import type { GatewayMeta } from './daemon.js';
|
||||||
|
import {
|
||||||
|
ENV_FILE,
|
||||||
|
GATEWAY_HOME,
|
||||||
|
ensureDirs,
|
||||||
|
installGatewayPackage,
|
||||||
|
readMeta,
|
||||||
|
resolveGatewayEntry,
|
||||||
|
startDaemon,
|
||||||
|
waitForHealth,
|
||||||
|
writeMeta,
|
||||||
|
getInstalledGatewayVersion,
|
||||||
|
} from './daemon.js';
|
||||||
|
|
||||||
|
interface InstallOpts {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
skipInstall?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function prompt(rl: ReturnType<typeof createInterface>, question: string): Promise<string> {
|
||||||
|
return new Promise((resolve) => rl.question(question, resolve));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runInstall(opts: InstallOpts): Promise<void> {
|
||||||
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||||
|
try {
|
||||||
|
await doInstall(rl, opts);
|
||||||
|
} finally {
|
||||||
|
rl.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doInstall(rl: ReturnType<typeof createInterface>, opts: InstallOpts): Promise<void> {
|
||||||
|
// Check existing installation
|
||||||
|
const existing = readMeta();
|
||||||
|
if (existing) {
|
||||||
|
const answer = await prompt(
|
||||||
|
rl,
|
||||||
|
`Gateway already installed (v${existing.version}). Reinstall? [y/N] `,
|
||||||
|
);
|
||||||
|
if (answer.toLowerCase() !== 'y') {
|
||||||
|
console.log('Aborted.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1: Install npm package
|
||||||
|
if (!opts.skipInstall) {
|
||||||
|
installGatewayPackage();
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureDirs();
|
||||||
|
|
||||||
|
// Step 2: Collect configuration
|
||||||
|
console.log('\n─── Gateway Configuration ───\n');
|
||||||
|
|
||||||
|
// Tier selection
|
||||||
|
console.log('Storage tier:');
|
||||||
|
console.log(' 1. Local (embedded database, no dependencies)');
|
||||||
|
console.log(' 2. Team (PostgreSQL + Valkey required)');
|
||||||
|
const tierAnswer = (await prompt(rl, 'Select [1]: ')).trim() || '1';
|
||||||
|
const tier = tierAnswer === '2' ? 'team' : 'local';
|
||||||
|
|
||||||
|
const port =
|
||||||
|
opts.port !== 14242
|
||||||
|
? opts.port
|
||||||
|
: parseInt(
|
||||||
|
(await prompt(rl, `Gateway port [${opts.port.toString()}]: `)) || opts.port.toString(),
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
|
||||||
|
let databaseUrl: string | undefined;
|
||||||
|
let valkeyUrl: string | undefined;
|
||||||
|
|
||||||
|
if (tier === 'team') {
|
||||||
|
databaseUrl =
|
||||||
|
(await prompt(rl, 'DATABASE_URL [postgresql://mosaic:mosaic@localhost:5433/mosaic]: ')) ||
|
||||||
|
'postgresql://mosaic:mosaic@localhost:5433/mosaic';
|
||||||
|
|
||||||
|
valkeyUrl =
|
||||||
|
(await prompt(rl, 'VALKEY_URL [redis://localhost:6380]: ')) || 'redis://localhost:6380';
|
||||||
|
}
|
||||||
|
|
||||||
|
const anthropicKey = await prompt(rl, 'ANTHROPIC_API_KEY (optional, press Enter to skip): ');
|
||||||
|
|
||||||
|
const corsOrigin =
|
||||||
|
(await prompt(rl, 'CORS origin [http://localhost:3000]: ')) || 'http://localhost:3000';
|
||||||
|
|
||||||
|
// Generate auth secret
|
||||||
|
const authSecret = randomBytes(32).toString('hex');
|
||||||
|
|
||||||
|
// Step 3: Write .env
|
||||||
|
const envLines = [
|
||||||
|
`GATEWAY_PORT=${port.toString()}`,
|
||||||
|
`BETTER_AUTH_SECRET=${authSecret}`,
|
||||||
|
`BETTER_AUTH_URL=http://${opts.host}:${port.toString()}`,
|
||||||
|
`GATEWAY_CORS_ORIGIN=${corsOrigin}`,
|
||||||
|
`OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318`,
|
||||||
|
`OTEL_SERVICE_NAME=mosaic-gateway`,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (tier === 'team' && databaseUrl && valkeyUrl) {
|
||||||
|
envLines.push(`DATABASE_URL=${databaseUrl}`);
|
||||||
|
envLines.push(`VALKEY_URL=${valkeyUrl}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (anthropicKey) {
|
||||||
|
envLines.push(`ANTHROPIC_API_KEY=${anthropicKey}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
writeFileSync(ENV_FILE, envLines.join('\n') + '\n', { mode: 0o600 });
|
||||||
|
console.log(`\nConfig written to ${ENV_FILE}`);
|
||||||
|
|
||||||
|
// Step 3b: Write mosaic.config.json
|
||||||
|
const mosaicConfig =
|
||||||
|
tier === 'local'
|
||||||
|
? {
|
||||||
|
tier: 'local',
|
||||||
|
storage: { type: 'pglite', dataDir: join(GATEWAY_HOME, 'storage-pglite') },
|
||||||
|
queue: { type: 'local', dataDir: join(GATEWAY_HOME, 'queue') },
|
||||||
|
memory: { type: 'keyword' },
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
tier: 'team',
|
||||||
|
storage: { type: 'postgres', url: databaseUrl },
|
||||||
|
queue: { type: 'bullmq', url: valkeyUrl },
|
||||||
|
memory: { type: 'pgvector' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const configFile = join(GATEWAY_HOME, 'mosaic.config.json');
|
||||||
|
writeFileSync(configFile, JSON.stringify(mosaicConfig, null, 2) + '\n', { mode: 0o600 });
|
||||||
|
console.log(`Config written to ${configFile}`);
|
||||||
|
|
||||||
|
// Step 4: Write meta.json
|
||||||
|
let entryPoint: string;
|
||||||
|
try {
|
||||||
|
entryPoint = resolveGatewayEntry();
|
||||||
|
} catch {
|
||||||
|
console.error('Error: Gateway package not found after install.');
|
||||||
|
console.error('Check that @mosaic/gateway installed correctly.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const version = getInstalledGatewayVersion() ?? 'unknown';
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
version,
|
||||||
|
installedAt: new Date().toISOString(),
|
||||||
|
entryPoint,
|
||||||
|
host: opts.host,
|
||||||
|
port,
|
||||||
|
};
|
||||||
|
writeMeta(meta);
|
||||||
|
|
||||||
|
// Step 5: Start the daemon
|
||||||
|
console.log('\nStarting gateway daemon...');
|
||||||
|
try {
|
||||||
|
const pid = startDaemon();
|
||||||
|
console.log(`Gateway started (PID ${pid.toString()})`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to start: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 6: Wait for health
|
||||||
|
console.log('Waiting for gateway to become healthy...');
|
||||||
|
const healthy = await waitForHealth(opts.host, port, 30_000);
|
||||||
|
if (!healthy) {
|
||||||
|
console.error('Gateway did not become healthy within 30 seconds.');
|
||||||
|
console.error(`Check logs: mosaic gateway logs`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log('Gateway is healthy.\n');
|
||||||
|
|
||||||
|
// Step 7: Bootstrap — first user setup
|
||||||
|
await bootstrapFirstUser(rl, opts.host, port, meta);
|
||||||
|
|
||||||
|
console.log('\n─── Installation Complete ───');
|
||||||
|
console.log(` Endpoint: http://${opts.host}:${port.toString()}`);
|
||||||
|
console.log(` Config: ${GATEWAY_HOME}`);
|
||||||
|
console.log(` Logs: mosaic gateway logs`);
|
||||||
|
console.log(` Status: mosaic gateway status`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bootstrapFirstUser(
|
||||||
|
rl: ReturnType<typeof createInterface>,
|
||||||
|
host: string,
|
||||||
|
port: number,
|
||||||
|
meta: Omit<GatewayMeta, 'adminToken'> & { adminToken?: string },
|
||||||
|
): Promise<void> {
|
||||||
|
const baseUrl = `http://${host}:${port.toString()}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const statusRes = await fetch(`${baseUrl}/api/bootstrap/status`);
|
||||||
|
if (!statusRes.ok) return;
|
||||||
|
|
||||||
|
const status = (await statusRes.json()) as { needsSetup: boolean };
|
||||||
|
if (!status.needsSetup) {
|
||||||
|
console.log('Admin user already exists — skipping setup.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
console.warn('Could not check bootstrap status — skipping first user setup.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('─── Admin User Setup ───\n');
|
||||||
|
|
||||||
|
const name = (await prompt(rl, 'Admin name: ')).trim();
|
||||||
|
if (!name) {
|
||||||
|
console.error('Name is required.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = (await prompt(rl, 'Admin email: ')).trim();
|
||||||
|
if (!email) {
|
||||||
|
console.error('Email is required.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const password = (await prompt(rl, 'Admin password (min 8 chars): ')).trim();
|
||||||
|
if (password.length < 8) {
|
||||||
|
console.error('Password must be at least 8 characters.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${baseUrl}/api/bootstrap/setup`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name, email, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.text().catch(() => '');
|
||||||
|
console.error(`Bootstrap failed (${res.status.toString()}): ${body}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = (await res.json()) as {
|
||||||
|
user: { id: string; email: string };
|
||||||
|
token: { plaintext: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save admin token to meta
|
||||||
|
meta.adminToken = result.token.plaintext;
|
||||||
|
writeMeta(meta as GatewayMeta);
|
||||||
|
|
||||||
|
console.log(`\nAdmin user created: ${result.user.email}`);
|
||||||
|
console.log('Admin API token saved to gateway config.');
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Bootstrap error: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
37
packages/mosaic/src/commands/gateway/logs.ts
Normal file
37
packages/mosaic/src/commands/gateway/logs.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { LOG_FILE } from './daemon.js';
|
||||||
|
|
||||||
|
interface LogsOpts {
|
||||||
|
follow?: boolean;
|
||||||
|
lines?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runLogs(opts: LogsOpts): void {
|
||||||
|
if (!existsSync(LOG_FILE)) {
|
||||||
|
console.log('No log file found. Is the gateway installed?');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.follow) {
|
||||||
|
const lines = opts.lines ?? 50;
|
||||||
|
const tail = spawn('tail', ['-n', lines.toString(), '-f', LOG_FILE], {
|
||||||
|
stdio: 'inherit',
|
||||||
|
});
|
||||||
|
tail.on('error', () => {
|
||||||
|
// Fallback for systems without tail
|
||||||
|
console.log(readLastLines(opts.lines ?? 50));
|
||||||
|
console.log('\n(--follow requires `tail` command)');
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Just print last N lines
|
||||||
|
console.log(readLastLines(opts.lines ?? 50));
|
||||||
|
}
|
||||||
|
|
||||||
|
function readLastLines(n: number): string {
|
||||||
|
const content = readFileSync(LOG_FILE, 'utf-8');
|
||||||
|
const lines = content.split('\n');
|
||||||
|
return lines.slice(-n).join('\n');
|
||||||
|
}
|
||||||
115
packages/mosaic/src/commands/gateway/status.ts
Normal file
115
packages/mosaic/src/commands/gateway/status.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { getDaemonPid, readMeta, LOG_FILE, GATEWAY_HOME } from './daemon.js';
|
||||||
|
|
||||||
|
interface GatewayOpts {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
token?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ServiceStatus {
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
latency?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AdminHealth {
|
||||||
|
status: string;
|
||||||
|
services: {
|
||||||
|
database: { status: string; latencyMs: number };
|
||||||
|
cache: { status: string; latencyMs: number };
|
||||||
|
};
|
||||||
|
agentPool?: { active: number };
|
||||||
|
providers?: Array<{ name: string; available: boolean; models: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runStatus(opts: GatewayOpts): Promise<void> {
|
||||||
|
const meta = readMeta();
|
||||||
|
const pid = getDaemonPid();
|
||||||
|
|
||||||
|
console.log('Mosaic Gateway Status');
|
||||||
|
console.log('─────────────────────');
|
||||||
|
|
||||||
|
// Daemon status
|
||||||
|
if (pid !== null) {
|
||||||
|
console.log(` Status: running (PID ${pid.toString()})`);
|
||||||
|
} else {
|
||||||
|
console.log(' Status: stopped');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Version
|
||||||
|
console.log(` Version: ${meta?.version ?? 'unknown'}`);
|
||||||
|
|
||||||
|
// Endpoint
|
||||||
|
const host = opts.host;
|
||||||
|
const port = opts.port;
|
||||||
|
console.log(` Endpoint: http://${host}:${port.toString()}`);
|
||||||
|
console.log(` Config: ${GATEWAY_HOME}`);
|
||||||
|
console.log(` Logs: ${LOG_FILE}`);
|
||||||
|
|
||||||
|
if (pid === null) return;
|
||||||
|
|
||||||
|
// Health check
|
||||||
|
try {
|
||||||
|
const healthRes = await fetch(`http://${host}:${port.toString()}/health`);
|
||||||
|
if (!healthRes.ok) {
|
||||||
|
console.log('\n Health: unreachable');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
console.log('\n Health: unreachable');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin health (requires token)
|
||||||
|
const token = opts.token ?? meta?.adminToken;
|
||||||
|
if (!token) {
|
||||||
|
console.log(
|
||||||
|
'\n (No admin token — run `mosaic gateway config` to set one for detailed status)',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`http://${host}:${port.toString()}/api/admin/health`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
console.log('\n Admin health: unauthorized or unavailable');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const health = (await res.json()) as AdminHealth;
|
||||||
|
|
||||||
|
console.log('\n Services:');
|
||||||
|
const services: ServiceStatus[] = [
|
||||||
|
{
|
||||||
|
name: 'Database',
|
||||||
|
status: health.services.database.status,
|
||||||
|
latency: `${health.services.database.latencyMs.toString()}ms`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Cache',
|
||||||
|
status: health.services.cache.status,
|
||||||
|
latency: `${health.services.cache.latencyMs.toString()}ms`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const svc of services) {
|
||||||
|
const latStr = svc.latency ? ` (${svc.latency})` : '';
|
||||||
|
console.log(` ${svc.name}:${' '.repeat(10 - svc.name.length)}${svc.status}${latStr}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (health.providers && health.providers.length > 0) {
|
||||||
|
const available = health.providers.filter((p) => p.available);
|
||||||
|
const names = available.map((p) => p.name).join(', ');
|
||||||
|
console.log(`\n Providers: ${available.length.toString()} active (${names})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (health.agentPool) {
|
||||||
|
console.log(` Sessions: ${health.agentPool.active.toString()} active`);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
console.log('\n Admin health: connection error');
|
||||||
|
}
|
||||||
|
}
|
||||||
62
packages/mosaic/src/commands/gateway/uninstall.ts
Normal file
62
packages/mosaic/src/commands/gateway/uninstall.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { existsSync, rmSync } from 'node:fs';
|
||||||
|
import { createInterface } from 'node:readline';
|
||||||
|
import {
|
||||||
|
GATEWAY_HOME,
|
||||||
|
getDaemonPid,
|
||||||
|
readMeta,
|
||||||
|
stopDaemon,
|
||||||
|
uninstallGatewayPackage,
|
||||||
|
} from './daemon.js';
|
||||||
|
|
||||||
|
export async function runUninstall(): Promise<void> {
|
||||||
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||||
|
try {
|
||||||
|
await doUninstall(rl);
|
||||||
|
} finally {
|
||||||
|
rl.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function prompt(rl: ReturnType<typeof createInterface>, question: string): Promise<string> {
|
||||||
|
return new Promise((resolve) => rl.question(question, resolve));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doUninstall(rl: ReturnType<typeof createInterface>): Promise<void> {
|
||||||
|
const meta = readMeta();
|
||||||
|
if (!meta) {
|
||||||
|
console.log('Gateway is not installed.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const answer = await prompt(rl, 'Uninstall Mosaic Gateway? [y/N] ');
|
||||||
|
if (answer.toLowerCase() !== 'y') {
|
||||||
|
console.log('Aborted.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop if running
|
||||||
|
if (getDaemonPid() !== null) {
|
||||||
|
console.log('Stopping gateway daemon...');
|
||||||
|
try {
|
||||||
|
await stopDaemon();
|
||||||
|
console.log('Stopped.');
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`Warning: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove config/data
|
||||||
|
const removeData = await prompt(rl, `Remove all gateway data at ${GATEWAY_HOME}? [y/N] `);
|
||||||
|
if (removeData.toLowerCase() === 'y') {
|
||||||
|
if (existsSync(GATEWAY_HOME)) {
|
||||||
|
rmSync(GATEWAY_HOME, { recursive: true, force: true });
|
||||||
|
console.log('Gateway data removed.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uninstall npm package
|
||||||
|
console.log('Uninstalling npm package...');
|
||||||
|
uninstallGatewayPackage();
|
||||||
|
|
||||||
|
console.log('\nGateway uninstalled.');
|
||||||
|
}
|
||||||
768
packages/mosaic/src/commands/launch.ts
Normal file
768
packages/mosaic/src/commands/launch.ts
Normal file
@@ -0,0 +1,768 @@
|
|||||||
|
/**
|
||||||
|
* Native runtime launcher — replaces the bash mosaic-launch script.
|
||||||
|
*
|
||||||
|
* Builds a composed runtime prompt from AGENTS.md + RUNTIME.md + USER.md +
|
||||||
|
* TOOLS.md + mission context + PRD status, then exec's into the target CLI.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execFileSync, execSync, spawnSync } from 'node:child_process';
|
||||||
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, rmSync } from 'node:fs';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { join, dirname } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import type { Command } from 'commander';
|
||||||
|
|
||||||
|
const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
|
||||||
|
|
||||||
|
type RuntimeName = 'claude' | 'codex' | 'opencode' | 'pi';
|
||||||
|
|
||||||
|
const RUNTIME_LABELS: Record<RuntimeName, string> = {
|
||||||
|
claude: 'Claude Code',
|
||||||
|
codex: 'Codex',
|
||||||
|
opencode: 'OpenCode',
|
||||||
|
pi: 'Pi',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Pre-flight checks ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function checkMosaicHome(): void {
|
||||||
|
if (!existsSync(MOSAIC_HOME)) {
|
||||||
|
console.error(`[mosaic] ERROR: ${MOSAIC_HOME} not found.`);
|
||||||
|
console.error(
|
||||||
|
'[mosaic] Install: bash <(curl -fsSL https://git.mosaicstack.dev/mosaic/mosaic-stack/raw/branch/main/tools/install.sh)',
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkFile(path: string, label: string): void {
|
||||||
|
if (!existsSync(path)) {
|
||||||
|
console.error(`[mosaic] ERROR: ${label} not found: ${path}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkRuntime(cmd: string): void {
|
||||||
|
try {
|
||||||
|
execSync(`which ${cmd}`, { stdio: 'ignore' });
|
||||||
|
} catch {
|
||||||
|
console.error(`[mosaic] ERROR: '${cmd}' not found in PATH.`);
|
||||||
|
console.error(`[mosaic] Install ${cmd} before launching.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkSoul(): void {
|
||||||
|
const soulPath = join(MOSAIC_HOME, 'SOUL.md');
|
||||||
|
if (!existsSync(soulPath)) {
|
||||||
|
console.log('[mosaic] SOUL.md not found. Running setup wizard...');
|
||||||
|
|
||||||
|
// Prefer the TypeScript wizard (idempotent, detects existing files)
|
||||||
|
try {
|
||||||
|
const result = spawnSync(process.execPath, [process.argv[1]!, 'wizard'], {
|
||||||
|
stdio: 'inherit',
|
||||||
|
});
|
||||||
|
if (result.status === 0 && existsSync(soulPath)) return;
|
||||||
|
} catch {
|
||||||
|
// Fall through to legacy init
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: legacy bash mosaic-init
|
||||||
|
const initBin = fwScript('mosaic-init');
|
||||||
|
if (existsSync(initBin)) {
|
||||||
|
spawnSync(initBin, [], { stdio: 'inherit' });
|
||||||
|
} else {
|
||||||
|
console.error('[mosaic] Setup failed. Run: mosaic wizard');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkSequentialThinking(runtime: string): void {
|
||||||
|
const checker = fwScript('mosaic-ensure-sequential-thinking');
|
||||||
|
if (!existsSync(checker)) return; // Skip if checker doesn't exist
|
||||||
|
const result = spawnSync(checker, ['--check', '--runtime', runtime], { stdio: 'ignore' });
|
||||||
|
if (result.status !== 0) {
|
||||||
|
console.error('[mosaic] ERROR: sequential-thinking MCP is required but not configured.');
|
||||||
|
console.error(`[mosaic] Fix: ${checker} --runtime ${runtime}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── File helpers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function readOptional(path: string): string {
|
||||||
|
try {
|
||||||
|
return readFileSync(path, 'utf-8');
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJson(path: string): Record<string, unknown> | null {
|
||||||
|
try {
|
||||||
|
return JSON.parse(readFileSync(path, 'utf-8')) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Mission context ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface MissionInfo {
|
||||||
|
name: string;
|
||||||
|
id: string;
|
||||||
|
status: string;
|
||||||
|
milestoneCount: number;
|
||||||
|
completedCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectMission(): MissionInfo | null {
|
||||||
|
const missionFile = '.mosaic/orchestrator/mission.json';
|
||||||
|
const data = readJson(missionFile);
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
|
const status = String(data['status'] ?? 'inactive');
|
||||||
|
if (status !== 'active' && status !== 'paused') return null;
|
||||||
|
|
||||||
|
const milestones = Array.isArray(data['milestones']) ? data['milestones'] : [];
|
||||||
|
const completed = milestones.filter(
|
||||||
|
(m) =>
|
||||||
|
typeof m === 'object' &&
|
||||||
|
m !== null &&
|
||||||
|
(m as Record<string, unknown>)['status'] === 'completed',
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: String(data['name'] ?? 'unnamed'),
|
||||||
|
id: String(data['mission_id'] ?? ''),
|
||||||
|
status,
|
||||||
|
milestoneCount: milestones.length,
|
||||||
|
completedCount: completed.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMissionBlock(mission: MissionInfo): string {
|
||||||
|
return `# ACTIVE MISSION — HARD GATE (Read Before Anything Else)
|
||||||
|
|
||||||
|
An active orchestration mission exists in this project. This is a BLOCKING requirement.
|
||||||
|
|
||||||
|
**Mission:** ${mission.name}
|
||||||
|
**ID:** ${mission.id}
|
||||||
|
**Status:** ${mission.status}
|
||||||
|
**Milestones:** ${mission.completedCount} / ${mission.milestoneCount} completed
|
||||||
|
|
||||||
|
## MANDATORY — Before ANY Response to the User
|
||||||
|
|
||||||
|
You MUST complete these steps before responding to any user message, including simple greetings:
|
||||||
|
|
||||||
|
1. Read \`~/.config/mosaic/guides/ORCHESTRATOR-PROTOCOL.md\` (mission lifecycle protocol)
|
||||||
|
2. Read \`docs/MISSION-MANIFEST.md\` for full mission scope, milestones, and success criteria
|
||||||
|
3. Read the latest scratchpad in \`docs/scratchpads/\` for session history, decisions, and corrections
|
||||||
|
4. Read \`docs/TASKS.md\` for current task state (what is done, what is next)
|
||||||
|
5. After reading all four, acknowledge the mission state to the user before proceeding
|
||||||
|
|
||||||
|
If the user gives a task, execute it within the mission context. If no task is given, present mission status and ask how to proceed.
|
||||||
|
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── PRD status ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function buildPrdBlock(): string {
|
||||||
|
const prdFile = 'docs/PRD.md';
|
||||||
|
if (!existsSync(prdFile)) return '';
|
||||||
|
|
||||||
|
const content = readFileSync(prdFile, 'utf-8');
|
||||||
|
const patterns = [
|
||||||
|
/^#{2,3} .*(problem statement|objective)/im,
|
||||||
|
/^#{2,3} .*(scope|non.goal|out of scope|in.scope)/im,
|
||||||
|
/^#{2,3} .*(user stor|stakeholder|user.*requirement)/im,
|
||||||
|
/^#{2,3} .*functional requirement/im,
|
||||||
|
/^#{2,3} .*non.functional/im,
|
||||||
|
/^#{2,3} .*acceptance criteria/im,
|
||||||
|
/^#{2,3} .*(technical consideration|constraint|dependenc)/im,
|
||||||
|
/^#{2,3} .*(risk|open question)/im,
|
||||||
|
/^#{2,3} .*(success metric|test|verification)/im,
|
||||||
|
/^#{2,3} .*(milestone|delivery|scope version)/im,
|
||||||
|
];
|
||||||
|
|
||||||
|
let sections = 0;
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
if (pattern.test(content)) sections++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const assumptions = (content.match(/ASSUMPTION:/g) ?? []).length;
|
||||||
|
const status = sections < 10 ? `incomplete (${sections}/10 sections)` : 'ready';
|
||||||
|
|
||||||
|
return `
|
||||||
|
# PRD Status
|
||||||
|
|
||||||
|
- **File:** docs/PRD.md
|
||||||
|
- **Status:** ${status}
|
||||||
|
- **Assumptions:** ${assumptions}
|
||||||
|
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Runtime prompt builder ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function buildRuntimePrompt(runtime: RuntimeName): string {
|
||||||
|
const runtimeContractPaths: Record<RuntimeName, string> = {
|
||||||
|
claude: join(MOSAIC_HOME, 'runtime', 'claude', 'RUNTIME.md'),
|
||||||
|
codex: join(MOSAIC_HOME, 'runtime', 'codex', 'RUNTIME.md'),
|
||||||
|
opencode: join(MOSAIC_HOME, 'runtime', 'opencode', 'RUNTIME.md'),
|
||||||
|
pi: join(MOSAIC_HOME, 'runtime', 'pi', 'RUNTIME.md'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const runtimeFile = runtimeContractPaths[runtime];
|
||||||
|
checkFile(runtimeFile, `Runtime contract for ${runtime}`);
|
||||||
|
|
||||||
|
const parts: string[] = [];
|
||||||
|
|
||||||
|
// Mission context (injected first)
|
||||||
|
const mission = detectMission();
|
||||||
|
if (mission) {
|
||||||
|
parts.push(buildMissionBlock(mission));
|
||||||
|
}
|
||||||
|
|
||||||
|
// PRD status
|
||||||
|
const prdBlock = buildPrdBlock();
|
||||||
|
if (prdBlock) parts.push(prdBlock);
|
||||||
|
|
||||||
|
// Hard gate
|
||||||
|
parts.push(`# Mosaic Launcher Runtime Contract (Hard Gate)
|
||||||
|
|
||||||
|
This contract is injected by \`mosaic\` launch and is mandatory.
|
||||||
|
|
||||||
|
First assistant response MUST start with exactly one mode declaration line:
|
||||||
|
1. Orchestration mission: \`Now initiating Orchestrator mode...\`
|
||||||
|
2. Implementation mission: \`Now initiating Delivery mode...\`
|
||||||
|
3. Review-only mission: \`Now initiating Review mode...\`
|
||||||
|
|
||||||
|
No tool call or implementation step may occur before that first line.
|
||||||
|
|
||||||
|
Mosaic hard gates OVERRIDE runtime-default caution for routine delivery operations.
|
||||||
|
For required push/merge/issue-close/release actions, execute without routine confirmation prompts.
|
||||||
|
`);
|
||||||
|
|
||||||
|
// AGENTS.md
|
||||||
|
parts.push(readFileSync(join(MOSAIC_HOME, 'AGENTS.md'), 'utf-8'));
|
||||||
|
|
||||||
|
// USER.md
|
||||||
|
const user = readOptional(join(MOSAIC_HOME, 'USER.md'));
|
||||||
|
if (user) parts.push('\n\n# User Profile\n\n' + user);
|
||||||
|
|
||||||
|
// TOOLS.md
|
||||||
|
const tools = readOptional(join(MOSAIC_HOME, 'TOOLS.md'));
|
||||||
|
if (tools) parts.push('\n\n# Machine Tools\n\n' + tools);
|
||||||
|
|
||||||
|
// Runtime-specific contract
|
||||||
|
parts.push('\n\n# Runtime-Specific Contract\n\n' + readFileSync(runtimeFile, 'utf-8'));
|
||||||
|
|
||||||
|
return parts.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Session lock ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function writeSessionLock(runtime: string): void {
|
||||||
|
const missionFile = '.mosaic/orchestrator/mission.json';
|
||||||
|
const lockFile = '.mosaic/orchestrator/session.lock';
|
||||||
|
const data = readJson(missionFile);
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
const status = String(data['status'] ?? 'inactive');
|
||||||
|
if (status !== 'active' && status !== 'paused') return;
|
||||||
|
|
||||||
|
const sessionId = `${runtime}-${new Date().toISOString().replace(/[:.]/g, '-')}-${process.pid}`;
|
||||||
|
const lock = {
|
||||||
|
session_id: sessionId,
|
||||||
|
runtime,
|
||||||
|
pid: process.pid,
|
||||||
|
started_at: new Date().toISOString(),
|
||||||
|
project_path: process.cwd(),
|
||||||
|
milestone_id: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
mkdirSync(dirname(lockFile), { recursive: true });
|
||||||
|
writeFileSync(lockFile, JSON.stringify(lock, null, 2) + '\n');
|
||||||
|
|
||||||
|
// Clean up on exit
|
||||||
|
const cleanup = () => {
|
||||||
|
try {
|
||||||
|
rmSync(lockFile, { force: true });
|
||||||
|
} catch {
|
||||||
|
// best-effort
|
||||||
|
}
|
||||||
|
};
|
||||||
|
process.on('exit', cleanup);
|
||||||
|
process.on('SIGINT', () => {
|
||||||
|
cleanup();
|
||||||
|
process.exit(130);
|
||||||
|
});
|
||||||
|
process.on('SIGTERM', () => {
|
||||||
|
cleanup();
|
||||||
|
process.exit(143);
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Non-fatal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Resumable session advisory ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
function checkResumableSession(): void {
|
||||||
|
const lockFile = '.mosaic/orchestrator/session.lock';
|
||||||
|
const missionFile = '.mosaic/orchestrator/mission.json';
|
||||||
|
|
||||||
|
if (existsSync(lockFile)) {
|
||||||
|
const lock = readJson(lockFile);
|
||||||
|
if (lock) {
|
||||||
|
const pid = Number(lock['pid'] ?? 0);
|
||||||
|
if (pid > 0) {
|
||||||
|
try {
|
||||||
|
process.kill(pid, 0); // Check if alive
|
||||||
|
} catch {
|
||||||
|
// Process is dead — stale lock
|
||||||
|
rmSync(lockFile, { force: true });
|
||||||
|
console.log(`[mosaic] Cleaned up stale session lock (PID ${pid} no longer running).\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (existsSync(missionFile)) {
|
||||||
|
const data = readJson(missionFile);
|
||||||
|
if (data && data['status'] === 'active') {
|
||||||
|
console.log('[mosaic] Active mission detected. Generate continuation prompt with:');
|
||||||
|
console.log('[mosaic] mosaic coord continue\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Write config for runtimes that read from fixed paths ────────────────────
|
||||||
|
|
||||||
|
function ensureRuntimeConfig(runtime: RuntimeName, destPath: string): void {
|
||||||
|
const prompt = buildRuntimePrompt(runtime);
|
||||||
|
mkdirSync(dirname(destPath), { recursive: true });
|
||||||
|
const existing = readOptional(destPath);
|
||||||
|
if (existing !== prompt) {
|
||||||
|
writeFileSync(destPath, prompt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Pi skill/extension discovery ────────────────────────────────────────────
|
||||||
|
|
||||||
|
function discoverPiSkills(): string[] {
|
||||||
|
const args: string[] = [];
|
||||||
|
for (const skillsRoot of [join(MOSAIC_HOME, 'skills'), join(MOSAIC_HOME, 'skills-local')]) {
|
||||||
|
if (!existsSync(skillsRoot)) continue;
|
||||||
|
try {
|
||||||
|
for (const entry of readdirSync(skillsRoot, { withFileTypes: true })) {
|
||||||
|
if (!entry.isDirectory()) continue;
|
||||||
|
const skillDir = join(skillsRoot, entry.name);
|
||||||
|
if (existsSync(join(skillDir, 'SKILL.md'))) {
|
||||||
|
args.push('--skill', skillDir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// skip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
function discoverPiExtension(): string[] {
|
||||||
|
const ext = join(MOSAIC_HOME, 'runtime', 'pi', 'mosaic-extension.ts');
|
||||||
|
return existsSync(ext) ? ['--extension', ext] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Launch functions ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function getMissionPrompt(): string {
|
||||||
|
const mission = detectMission();
|
||||||
|
if (!mission) return '';
|
||||||
|
return `Active mission detected: ${mission.name}. Read the mission state files and report status.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): never {
|
||||||
|
checkMosaicHome();
|
||||||
|
checkFile(join(MOSAIC_HOME, 'AGENTS.md'), 'AGENTS.md');
|
||||||
|
checkSoul();
|
||||||
|
checkRuntime(runtime);
|
||||||
|
|
||||||
|
// Pi doesn't need sequential-thinking (has native thinking levels)
|
||||||
|
if (runtime !== 'pi') {
|
||||||
|
checkSequentialThinking(runtime);
|
||||||
|
}
|
||||||
|
|
||||||
|
checkResumableSession();
|
||||||
|
|
||||||
|
const missionPrompt = getMissionPrompt();
|
||||||
|
const hasMissionNoArgs = missionPrompt && args.length === 0;
|
||||||
|
const label = RUNTIME_LABELS[runtime];
|
||||||
|
const modeStr = yolo ? ' in YOLO mode' : '';
|
||||||
|
const missionStr = hasMissionNoArgs ? ' (active mission detected)' : '';
|
||||||
|
|
||||||
|
writeSessionLock(runtime);
|
||||||
|
|
||||||
|
switch (runtime) {
|
||||||
|
case 'claude': {
|
||||||
|
const prompt = buildRuntimePrompt('claude');
|
||||||
|
const cliArgs = yolo ? ['--dangerously-skip-permissions'] : [];
|
||||||
|
cliArgs.push('--append-system-prompt', prompt);
|
||||||
|
if (hasMissionNoArgs) {
|
||||||
|
cliArgs.push(missionPrompt);
|
||||||
|
} else {
|
||||||
|
cliArgs.push(...args);
|
||||||
|
}
|
||||||
|
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
||||||
|
execRuntime('claude', cliArgs);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'codex': {
|
||||||
|
ensureRuntimeConfig('codex', join(homedir(), '.codex', 'instructions.md'));
|
||||||
|
const cliArgs = yolo ? ['--dangerously-bypass-approvals-and-sandbox'] : [];
|
||||||
|
if (hasMissionNoArgs) {
|
||||||
|
cliArgs.push(missionPrompt);
|
||||||
|
} else {
|
||||||
|
cliArgs.push(...args);
|
||||||
|
}
|
||||||
|
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
||||||
|
execRuntime('codex', cliArgs);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'opencode': {
|
||||||
|
ensureRuntimeConfig('opencode', join(homedir(), '.config', 'opencode', 'AGENTS.md'));
|
||||||
|
console.log(`[mosaic] Launching ${label}${modeStr}...`);
|
||||||
|
execRuntime('opencode', args);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'pi': {
|
||||||
|
const prompt = buildRuntimePrompt('pi');
|
||||||
|
const cliArgs = ['--append-system-prompt', prompt];
|
||||||
|
cliArgs.push(...discoverPiSkills());
|
||||||
|
cliArgs.push(...discoverPiExtension());
|
||||||
|
if (hasMissionNoArgs) {
|
||||||
|
cliArgs.push(missionPrompt);
|
||||||
|
} else {
|
||||||
|
cliArgs.push(...args);
|
||||||
|
}
|
||||||
|
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
||||||
|
execRuntime('pi', cliArgs);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(0); // Unreachable but satisfies never
|
||||||
|
}
|
||||||
|
|
||||||
|
/** exec into the runtime, replacing the current process. */
|
||||||
|
function execRuntime(cmd: string, args: string[]): void {
|
||||||
|
try {
|
||||||
|
// Use execFileSync with inherited stdio to replace the process
|
||||||
|
const result = spawnSync(cmd, args, {
|
||||||
|
stdio: 'inherit',
|
||||||
|
env: process.env,
|
||||||
|
});
|
||||||
|
process.exit(result.status ?? 0);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[mosaic] Failed to launch ${cmd}:`, err instanceof Error ? err.message : err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Framework script/tool delegation ───────────────────────────────────────
|
||||||
|
|
||||||
|
function delegateToScript(scriptPath: string, args: string[], env?: Record<string, string>): never {
|
||||||
|
if (!existsSync(scriptPath)) {
|
||||||
|
console.error(`[mosaic] Script not found: ${scriptPath}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
execFileSync('bash', [scriptPath, ...args], {
|
||||||
|
stdio: 'inherit',
|
||||||
|
env: { ...process.env, ...env },
|
||||||
|
});
|
||||||
|
process.exit(0);
|
||||||
|
} catch (err) {
|
||||||
|
process.exit((err as { status?: number }).status ?? 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a path under the framework tools directory. Prefers the version
|
||||||
|
* bundled in the @mosaic/mosaic npm package (always matches the installed
|
||||||
|
* CLI version) over the deployed copy in ~/.config/mosaic/ (may be stale).
|
||||||
|
*/
|
||||||
|
function resolveTool(...segments: string[]): string {
|
||||||
|
// Resolve relative to the built file: dist/commands/launch.js → ../../framework/tools/...
|
||||||
|
const thisFile = fileURLToPath(import.meta.url);
|
||||||
|
const bundled = join(dirname(thisFile), '..', '..', 'framework', 'tools', ...segments);
|
||||||
|
if (existsSync(bundled)) return bundled;
|
||||||
|
return join(MOSAIC_HOME, 'tools', ...segments);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fwScript(name: string): string {
|
||||||
|
return resolveTool('_scripts', name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolScript(toolDir: string, name: string): string {
|
||||||
|
return resolveTool(toolDir, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Coord (mission orchestrator) ───────────────────────────────────────────
|
||||||
|
|
||||||
|
const COORD_SUBCMDS: Record<string, string> = {
|
||||||
|
status: 'session-status.sh',
|
||||||
|
session: 'session-status.sh',
|
||||||
|
init: 'mission-init.sh',
|
||||||
|
mission: 'mission-status.sh',
|
||||||
|
progress: 'mission-status.sh',
|
||||||
|
continue: 'continue-prompt.sh',
|
||||||
|
next: 'continue-prompt.sh',
|
||||||
|
run: 'session-run.sh',
|
||||||
|
start: 'session-run.sh',
|
||||||
|
smoke: 'smoke-test.sh',
|
||||||
|
test: 'smoke-test.sh',
|
||||||
|
resume: 'session-resume.sh',
|
||||||
|
recover: 'session-resume.sh',
|
||||||
|
};
|
||||||
|
|
||||||
|
function runCoord(args: string[]): never {
|
||||||
|
checkMosaicHome();
|
||||||
|
let runtime = 'claude';
|
||||||
|
let yoloFlag = '';
|
||||||
|
const coordArgs: string[] = [];
|
||||||
|
|
||||||
|
for (const arg of args) {
|
||||||
|
if (arg === '--claude' || arg === '--codex' || arg === '--pi') {
|
||||||
|
runtime = arg.slice(2);
|
||||||
|
} else if (arg === '--yolo') {
|
||||||
|
yoloFlag = '--yolo';
|
||||||
|
} else {
|
||||||
|
coordArgs.push(arg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const subcmd = coordArgs[0] ?? 'help';
|
||||||
|
const subArgs = coordArgs.slice(1);
|
||||||
|
const script = COORD_SUBCMDS[subcmd];
|
||||||
|
|
||||||
|
if (!script) {
|
||||||
|
console.log(`mosaic coord — mission coordinator tools
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
init --name <name> [opts] Initialize a new mission
|
||||||
|
mission [--project <path>] Show mission progress dashboard
|
||||||
|
status [--project <path>] Check agent session health
|
||||||
|
continue [--project <path>] Generate continuation prompt
|
||||||
|
run [--project <path>] Launch runtime with mission context
|
||||||
|
smoke Run orchestration smoke checks
|
||||||
|
resume [--project <path>] Crash recovery
|
||||||
|
|
||||||
|
Runtime: --claude (default) | --codex | --pi | --yolo`);
|
||||||
|
process.exit(subcmd === 'help' ? 0 : 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (yoloFlag) subArgs.unshift(yoloFlag);
|
||||||
|
delegateToScript(toolScript('orchestrator', script), subArgs, {
|
||||||
|
MOSAIC_COORD_RUNTIME: runtime,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Prdy (PRD tools via framework scripts) ─────────────────────────────────
|
||||||
|
|
||||||
|
const PRDY_SUBCMDS: Record<string, string> = {
|
||||||
|
init: 'prdy-init.sh',
|
||||||
|
update: 'prdy-update.sh',
|
||||||
|
validate: 'prdy-validate.sh',
|
||||||
|
check: 'prdy-validate.sh',
|
||||||
|
status: 'prdy-status.sh',
|
||||||
|
};
|
||||||
|
|
||||||
|
function runPrdyLocal(args: string[]): never {
|
||||||
|
checkMosaicHome();
|
||||||
|
let runtime = 'claude';
|
||||||
|
const prdyArgs: string[] = [];
|
||||||
|
|
||||||
|
for (const arg of args) {
|
||||||
|
if (arg === '--claude' || arg === '--codex' || arg === '--pi') {
|
||||||
|
runtime = arg.slice(2);
|
||||||
|
} else {
|
||||||
|
prdyArgs.push(arg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const subcmd = prdyArgs[0] ?? 'help';
|
||||||
|
const subArgs = prdyArgs.slice(1);
|
||||||
|
const script = PRDY_SUBCMDS[subcmd];
|
||||||
|
|
||||||
|
if (!script) {
|
||||||
|
console.log(`mosaic prdy — PRD creation and validation
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
init [--project <path>] [--name <feature>] Create docs/PRD.md
|
||||||
|
update [--project <path>] Update existing PRD
|
||||||
|
validate [--project <path>] Check PRD completeness
|
||||||
|
status [--project <path>] Quick PRD health check
|
||||||
|
|
||||||
|
Runtime: --claude (default) | --codex | --pi`);
|
||||||
|
process.exit(subcmd === 'help' ? 0 : 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
delegateToScript(toolScript('prdy', script), subArgs, {
|
||||||
|
MOSAIC_PRDY_RUNTIME: runtime,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Seq (sequential-thinking MCP) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
function runSeq(args: string[]): never {
|
||||||
|
checkMosaicHome();
|
||||||
|
const action = args[0] ?? 'check';
|
||||||
|
const rest = args.slice(1);
|
||||||
|
const checker = fwScript('mosaic-ensure-sequential-thinking');
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'check':
|
||||||
|
delegateToScript(checker, ['--check', ...rest]);
|
||||||
|
break; // unreachable
|
||||||
|
case 'fix':
|
||||||
|
case 'apply':
|
||||||
|
delegateToScript(checker, rest);
|
||||||
|
break;
|
||||||
|
case 'start': {
|
||||||
|
console.log('[mosaic] Starting sequential-thinking MCP server...');
|
||||||
|
try {
|
||||||
|
execFileSync('npx', ['-y', '@modelcontextprotocol/server-sequential-thinking', ...rest], {
|
||||||
|
stdio: 'inherit',
|
||||||
|
});
|
||||||
|
process.exit(0);
|
||||||
|
} catch (err) {
|
||||||
|
process.exit((err as { status?: number }).status ?? 1);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
console.error(`[mosaic] Unknown seq subcommand '${action}'. Use: check|fix|start`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Upgrade ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function runUpgrade(args: string[]): never {
|
||||||
|
checkMosaicHome();
|
||||||
|
const subcmd = args[0];
|
||||||
|
|
||||||
|
if (!subcmd || subcmd === 'release') {
|
||||||
|
delegateToScript(fwScript('mosaic-release-upgrade'), args.slice(subcmd === 'release' ? 1 : 0));
|
||||||
|
} else if (subcmd === 'check') {
|
||||||
|
delegateToScript(fwScript('mosaic-release-upgrade'), ['--dry-run', ...args.slice(1)]);
|
||||||
|
} else if (subcmd === 'project') {
|
||||||
|
delegateToScript(fwScript('mosaic-upgrade'), args.slice(1));
|
||||||
|
} else if (subcmd.startsWith('-')) {
|
||||||
|
delegateToScript(fwScript('mosaic-release-upgrade'), args);
|
||||||
|
} else {
|
||||||
|
delegateToScript(fwScript('mosaic-upgrade'), args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Commander registration ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function registerLaunchCommands(program: Command): void {
|
||||||
|
// Runtime launchers
|
||||||
|
for (const runtime of ['claude', 'codex', 'opencode', 'pi'] as const) {
|
||||||
|
program
|
||||||
|
.command(runtime)
|
||||||
|
.description(`Launch ${RUNTIME_LABELS[runtime]} with Mosaic injection`)
|
||||||
|
.allowUnknownOption(true)
|
||||||
|
.allowExcessArguments(true)
|
||||||
|
.action((_opts: unknown, cmd: Command) => {
|
||||||
|
launchRuntime(runtime, cmd.args, false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Yolo mode
|
||||||
|
program
|
||||||
|
.command('yolo <runtime>')
|
||||||
|
.description('Launch a runtime in dangerous-permissions mode (claude|codex|opencode|pi)')
|
||||||
|
.allowUnknownOption(true)
|
||||||
|
.allowExcessArguments(true)
|
||||||
|
.action((runtime: string, _opts: unknown, cmd: Command) => {
|
||||||
|
const valid: RuntimeName[] = ['claude', 'codex', 'opencode', 'pi'];
|
||||||
|
if (!valid.includes(runtime as RuntimeName)) {
|
||||||
|
console.error(
|
||||||
|
`[mosaic] ERROR: Unsupported yolo runtime '${runtime}'. Use: ${valid.join('|')}`,
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
launchRuntime(runtime as RuntimeName, cmd.args, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Coord (mission orchestrator)
|
||||||
|
program
|
||||||
|
.command('coord')
|
||||||
|
.description('Mission coordinator tools (init, status, run, continue, resume)')
|
||||||
|
.allowUnknownOption(true)
|
||||||
|
.allowExcessArguments(true)
|
||||||
|
.action((_opts: unknown, cmd: Command) => {
|
||||||
|
runCoord(cmd.args);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Prdy (PRD tools via local framework scripts)
|
||||||
|
program
|
||||||
|
.command('prdy')
|
||||||
|
.description('PRD creation and validation (init, update, validate, status)')
|
||||||
|
.allowUnknownOption(true)
|
||||||
|
.allowExcessArguments(true)
|
||||||
|
.action((_opts: unknown, cmd: Command) => {
|
||||||
|
runPrdyLocal(cmd.args);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Seq (sequential-thinking MCP management)
|
||||||
|
program
|
||||||
|
.command('seq')
|
||||||
|
.description('sequential-thinking MCP management (check/fix/start)')
|
||||||
|
.allowUnknownOption(true)
|
||||||
|
.allowExcessArguments(true)
|
||||||
|
.action((_opts: unknown, cmd: Command) => {
|
||||||
|
runSeq(cmd.args);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Upgrade (release + project)
|
||||||
|
program
|
||||||
|
.command('upgrade')
|
||||||
|
.description('Upgrade Mosaic release or project files')
|
||||||
|
.allowUnknownOption(true)
|
||||||
|
.allowExcessArguments(true)
|
||||||
|
.action((_opts: unknown, cmd: Command) => {
|
||||||
|
runUpgrade(cmd.args);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Direct framework script delegates
|
||||||
|
const directCommands: Record<string, { desc: string; script: string }> = {
|
||||||
|
init: { desc: 'Generate SOUL.md (agent identity contract)', script: 'mosaic-init' },
|
||||||
|
doctor: { desc: 'Health audit — detect drift and missing files', script: 'mosaic-doctor' },
|
||||||
|
sync: { desc: 'Sync skills from canonical source', script: 'mosaic-sync-skills' },
|
||||||
|
bootstrap: {
|
||||||
|
desc: 'Bootstrap a repo with Mosaic standards',
|
||||||
|
script: 'mosaic-bootstrap-repo',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [name, { desc, script }] of Object.entries(directCommands)) {
|
||||||
|
program
|
||||||
|
.command(name)
|
||||||
|
.description(desc)
|
||||||
|
.allowUnknownOption(true)
|
||||||
|
.allowExcessArguments(true)
|
||||||
|
.action((_opts: unknown, cmd: Command) => {
|
||||||
|
checkMosaicHome();
|
||||||
|
delegateToScript(fwScript(script), cmd.args);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
385
packages/mosaic/src/commands/mission.ts
Normal file
385
packages/mosaic/src/commands/mission.ts
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
import type { Command } from 'commander';
|
||||||
|
import { withAuth } from './with-auth.js';
|
||||||
|
import { selectItem } from './select-dialog.js';
|
||||||
|
import {
|
||||||
|
fetchMissions,
|
||||||
|
fetchMission,
|
||||||
|
createMission,
|
||||||
|
updateMission,
|
||||||
|
fetchMissionTasks,
|
||||||
|
createMissionTask,
|
||||||
|
updateMissionTask,
|
||||||
|
fetchProjects,
|
||||||
|
} from '../tui/gateway-api.js';
|
||||||
|
import type { MissionInfo, MissionTaskInfo } from '../tui/gateway-api.js';
|
||||||
|
|
||||||
|
function formatMission(m: MissionInfo): string {
|
||||||
|
return `${m.name} — ${m.status}${m.phase ? ` (${m.phase})` : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showMissionDetail(m: MissionInfo) {
|
||||||
|
console.log(` ID: ${m.id}`);
|
||||||
|
console.log(` Name: ${m.name}`);
|
||||||
|
console.log(` Status: ${m.status}`);
|
||||||
|
console.log(` Phase: ${m.phase ?? '—'}`);
|
||||||
|
console.log(` Project: ${m.projectId ?? '—'}`);
|
||||||
|
console.log(` Description: ${m.description ?? '—'}`);
|
||||||
|
console.log(` Created: ${new Date(m.createdAt).toLocaleString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showTaskDetail(t: MissionTaskInfo) {
|
||||||
|
console.log(` ID: ${t.id}`);
|
||||||
|
console.log(` Status: ${t.status}`);
|
||||||
|
console.log(` Description: ${t.description ?? '—'}`);
|
||||||
|
console.log(` Notes: ${t.notes ?? '—'}`);
|
||||||
|
console.log(` PR: ${t.pr ?? '—'}`);
|
||||||
|
console.log(` Created: ${new Date(t.createdAt).toLocaleString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerMissionCommand(program: Command) {
|
||||||
|
const cmd = program
|
||||||
|
.command('mission')
|
||||||
|
.description('Manage missions')
|
||||||
|
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
|
||||||
|
.option('--list', 'List all missions')
|
||||||
|
.option('--init', 'Create a new mission')
|
||||||
|
.option('--plan <idOrName>', 'Run PRD wizard for a mission')
|
||||||
|
.option('--update <idOrName>', 'Update a mission')
|
||||||
|
.option('--project <idOrName>', 'Scope to project')
|
||||||
|
.argument('[id]', 'Show mission detail by ID')
|
||||||
|
.action(
|
||||||
|
async (
|
||||||
|
id: string | undefined,
|
||||||
|
opts: {
|
||||||
|
gateway: string;
|
||||||
|
list?: boolean;
|
||||||
|
init?: boolean;
|
||||||
|
plan?: string;
|
||||||
|
update?: string;
|
||||||
|
project?: string;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
const auth = await withAuth(opts.gateway);
|
||||||
|
|
||||||
|
if (opts.list) {
|
||||||
|
return listMissions(auth.gateway, auth.cookie);
|
||||||
|
}
|
||||||
|
if (opts.init) {
|
||||||
|
return initMission(auth.gateway, auth.cookie);
|
||||||
|
}
|
||||||
|
if (opts.plan) {
|
||||||
|
return planMission(auth.gateway, auth.cookie, opts.plan, opts.project);
|
||||||
|
}
|
||||||
|
if (opts.update) {
|
||||||
|
return updateMissionWizard(auth.gateway, auth.cookie, opts.update);
|
||||||
|
}
|
||||||
|
if (id) {
|
||||||
|
return showMission(auth.gateway, auth.cookie, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: interactive select
|
||||||
|
return interactiveSelect(auth.gateway, auth.cookie);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Task subcommand
|
||||||
|
cmd
|
||||||
|
.command('task')
|
||||||
|
.description('Manage mission tasks')
|
||||||
|
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
|
||||||
|
.option('--list', 'List tasks for a mission')
|
||||||
|
.option('--new', 'Create a task')
|
||||||
|
.option('--update <taskId>', 'Update a task')
|
||||||
|
.option('--mission <idOrName>', 'Mission ID or name')
|
||||||
|
.argument('[taskId]', 'Show task detail')
|
||||||
|
.action(
|
||||||
|
async (
|
||||||
|
taskId: string | undefined,
|
||||||
|
taskOpts: {
|
||||||
|
gateway: string;
|
||||||
|
list?: boolean;
|
||||||
|
new?: boolean;
|
||||||
|
update?: string;
|
||||||
|
mission?: string;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
const auth = await withAuth(taskOpts.gateway);
|
||||||
|
|
||||||
|
const missionId = await resolveMissionId(auth.gateway, auth.cookie, taskOpts.mission);
|
||||||
|
if (!missionId) return;
|
||||||
|
|
||||||
|
if (taskOpts.list) {
|
||||||
|
return listTasks(auth.gateway, auth.cookie, missionId);
|
||||||
|
}
|
||||||
|
if (taskOpts.new) {
|
||||||
|
return createTaskWizard(auth.gateway, auth.cookie, missionId);
|
||||||
|
}
|
||||||
|
if (taskOpts.update) {
|
||||||
|
return updateTaskWizard(auth.gateway, auth.cookie, missionId, taskOpts.update);
|
||||||
|
}
|
||||||
|
if (taskId) {
|
||||||
|
return showTask(auth.gateway, auth.cookie, missionId, taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return listTasks(auth.gateway, auth.cookie, missionId);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return cmd;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveMissionByName(
|
||||||
|
gateway: string,
|
||||||
|
cookie: string,
|
||||||
|
idOrName: string,
|
||||||
|
): Promise<MissionInfo | undefined> {
|
||||||
|
const missions = await fetchMissions(gateway, cookie);
|
||||||
|
return missions.find((m) => m.id === idOrName || m.name === idOrName);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveMissionId(
|
||||||
|
gateway: string,
|
||||||
|
cookie: string,
|
||||||
|
idOrName?: string,
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
if (idOrName) {
|
||||||
|
const mission = await resolveMissionByName(gateway, cookie, idOrName);
|
||||||
|
if (!mission) {
|
||||||
|
console.error(`Mission "${idOrName}" not found.`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return mission.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interactive select
|
||||||
|
const missions = await fetchMissions(gateway, cookie);
|
||||||
|
const selected = await selectItem(missions, {
|
||||||
|
message: 'Select a mission:',
|
||||||
|
render: formatMission,
|
||||||
|
emptyMessage: 'No missions found. Create one with `mosaic mission --init`.',
|
||||||
|
});
|
||||||
|
return selected?.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listMissions(gateway: string, cookie: string) {
|
||||||
|
const missions = await fetchMissions(gateway, cookie);
|
||||||
|
if (missions.length === 0) {
|
||||||
|
console.log('No missions found.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(`Missions (${missions.length}):\n`);
|
||||||
|
for (const m of missions) {
|
||||||
|
const phase = m.phase ? ` [${m.phase}]` : '';
|
||||||
|
console.log(` ${m.name} ${m.status}${phase} ${m.id.slice(0, 8)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showMission(gateway: string, cookie: string, id: string) {
|
||||||
|
try {
|
||||||
|
const mission = await fetchMission(gateway, cookie, id);
|
||||||
|
showMissionDetail(mission);
|
||||||
|
} catch {
|
||||||
|
// Try resolving by name
|
||||||
|
const m = await resolveMissionByName(gateway, cookie, id);
|
||||||
|
if (!m) {
|
||||||
|
console.error(`Mission "${id}" not found.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
showMissionDetail(m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function interactiveSelect(gateway: string, cookie: string) {
|
||||||
|
const missions = await fetchMissions(gateway, cookie);
|
||||||
|
const selected = await selectItem(missions, {
|
||||||
|
message: 'Select a mission:',
|
||||||
|
render: formatMission,
|
||||||
|
emptyMessage: 'No missions found. Create one with `mosaic mission --init`.',
|
||||||
|
});
|
||||||
|
if (selected) {
|
||||||
|
showMissionDetail(selected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initMission(gateway: string, cookie: string) {
|
||||||
|
const readline = await import('node:readline');
|
||||||
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||||
|
const ask = (q: string): Promise<string> => new Promise((resolve) => rl.question(q, resolve));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const name = await ask('Mission name: ');
|
||||||
|
if (!name.trim()) {
|
||||||
|
console.error('Name is required.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Project selection
|
||||||
|
const projects = await fetchProjects(gateway, cookie);
|
||||||
|
let projectId: string | undefined;
|
||||||
|
if (projects.length > 0) {
|
||||||
|
const selected = await selectItem(projects, {
|
||||||
|
message: 'Assign to project (required):',
|
||||||
|
render: (p) => `${p.name} (${p.status})`,
|
||||||
|
emptyMessage: 'No projects found.',
|
||||||
|
});
|
||||||
|
if (selected) projectId = selected.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const description = await ask('Description (optional): ');
|
||||||
|
|
||||||
|
const mission = await createMission(gateway, cookie, {
|
||||||
|
name: name.trim(),
|
||||||
|
projectId,
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
status: 'planning',
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`\nMission "${mission.name}" created (${mission.id}).`);
|
||||||
|
} finally {
|
||||||
|
rl.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function planMission(
|
||||||
|
gateway: string,
|
||||||
|
cookie: string,
|
||||||
|
idOrName: string,
|
||||||
|
_projectIdOrName?: string,
|
||||||
|
) {
|
||||||
|
const mission = await resolveMissionByName(gateway, cookie, idOrName);
|
||||||
|
if (!mission) {
|
||||||
|
console.error(`Mission "${idOrName}" not found.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Planning mission: ${mission.name}\n`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { runPrdWizard } = await import('@mosaic/prdy');
|
||||||
|
await runPrdWizard({
|
||||||
|
name: mission.name,
|
||||||
|
projectPath: process.cwd(),
|
||||||
|
interactive: true,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`PRD wizard failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateMissionWizard(gateway: string, cookie: string, idOrName: string) {
|
||||||
|
const mission = await resolveMissionByName(gateway, cookie, idOrName);
|
||||||
|
if (!mission) {
|
||||||
|
console.error(`Mission "${idOrName}" not found.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const readline = await import('node:readline');
|
||||||
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||||
|
const ask = (q: string): Promise<string> => new Promise((resolve) => rl.question(q, resolve));
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`Updating mission: ${mission.name}\n`);
|
||||||
|
|
||||||
|
const name = await ask(`Name [${mission.name}]: `);
|
||||||
|
const description = await ask(`Description [${mission.description ?? 'none'}]: `);
|
||||||
|
const status = await ask(`Status [${mission.status}]: `);
|
||||||
|
|
||||||
|
const updates: Record<string, unknown> = {};
|
||||||
|
if (name.trim()) updates['name'] = name.trim();
|
||||||
|
if (description.trim()) updates['description'] = description.trim();
|
||||||
|
if (status.trim()) updates['status'] = status.trim();
|
||||||
|
|
||||||
|
if (Object.keys(updates).length === 0) {
|
||||||
|
console.log('No changes.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await updateMission(gateway, cookie, mission.id, updates);
|
||||||
|
console.log(`\nMission "${updated.name}" updated.`);
|
||||||
|
} finally {
|
||||||
|
rl.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Task operations ──
|
||||||
|
|
||||||
|
async function listTasks(gateway: string, cookie: string, missionId: string) {
|
||||||
|
const tasks = await fetchMissionTasks(gateway, cookie, missionId);
|
||||||
|
if (tasks.length === 0) {
|
||||||
|
console.log('No tasks found.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(`Tasks (${tasks.length}):\n`);
|
||||||
|
for (const t of tasks) {
|
||||||
|
const desc = t.description ? ` — ${t.description.slice(0, 60)}` : '';
|
||||||
|
console.log(` ${t.id.slice(0, 8)} ${t.status}${desc}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showTask(gateway: string, cookie: string, missionId: string, taskId: string) {
|
||||||
|
const tasks = await fetchMissionTasks(gateway, cookie, missionId);
|
||||||
|
const task = tasks.find((t) => t.id === taskId);
|
||||||
|
if (!task) {
|
||||||
|
console.error(`Task "${taskId}" not found.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
showTaskDetail(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTaskWizard(gateway: string, cookie: string, missionId: string) {
|
||||||
|
const readline = await import('node:readline');
|
||||||
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||||
|
const ask = (q: string): Promise<string> => new Promise((resolve) => rl.question(q, resolve));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const description = await ask('Task description: ');
|
||||||
|
if (!description.trim()) {
|
||||||
|
console.error('Description is required.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = await ask('Status [not-started]: ');
|
||||||
|
|
||||||
|
const task = await createMissionTask(gateway, cookie, missionId, {
|
||||||
|
description: description.trim(),
|
||||||
|
status: status.trim() || 'not-started',
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`\nTask created (${task.id}).`);
|
||||||
|
} finally {
|
||||||
|
rl.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateTaskWizard(
|
||||||
|
gateway: string,
|
||||||
|
cookie: string,
|
||||||
|
missionId: string,
|
||||||
|
taskId: string,
|
||||||
|
) {
|
||||||
|
const readline = await import('node:readline');
|
||||||
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||||
|
const ask = (q: string): Promise<string> => new Promise((resolve) => rl.question(q, resolve));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const status = await ask('New status: ');
|
||||||
|
const notes = await ask('Notes (optional): ');
|
||||||
|
const pr = await ask('PR (optional): ');
|
||||||
|
|
||||||
|
const updates: Record<string, unknown> = {};
|
||||||
|
if (status.trim()) updates['status'] = status.trim();
|
||||||
|
if (notes.trim()) updates['notes'] = notes.trim();
|
||||||
|
if (pr.trim()) updates['pr'] = pr.trim();
|
||||||
|
|
||||||
|
if (Object.keys(updates).length === 0) {
|
||||||
|
console.log('No changes.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await updateMissionTask(gateway, cookie, missionId, taskId, updates);
|
||||||
|
console.log(`\nTask ${updated.id.slice(0, 8)} updated (${updated.status}).`);
|
||||||
|
} finally {
|
||||||
|
rl.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
55
packages/mosaic/src/commands/prdy.ts
Normal file
55
packages/mosaic/src/commands/prdy.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import type { Command } from 'commander';
|
||||||
|
import { withAuth } from './with-auth.js';
|
||||||
|
import { fetchProjects } from '../tui/gateway-api.js';
|
||||||
|
|
||||||
|
export function registerPrdyCommand(program: Command) {
|
||||||
|
const cmd = program
|
||||||
|
.command('prdy')
|
||||||
|
.description('PRD wizard — create and manage Product Requirement Documents')
|
||||||
|
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
|
||||||
|
.option('--init [name]', 'Create a new PRD')
|
||||||
|
.option('--update [name]', 'Update an existing PRD')
|
||||||
|
.option('--project <idOrName>', 'Scope to project')
|
||||||
|
.action(
|
||||||
|
async (opts: {
|
||||||
|
gateway: string;
|
||||||
|
init?: string | boolean;
|
||||||
|
update?: string | boolean;
|
||||||
|
project?: string;
|
||||||
|
}) => {
|
||||||
|
// Detect project context when --project flag is provided
|
||||||
|
if (opts.project) {
|
||||||
|
try {
|
||||||
|
const auth = await withAuth(opts.gateway);
|
||||||
|
const projects = await fetchProjects(auth.gateway, auth.cookie);
|
||||||
|
const match = projects.find((p) => p.id === opts.project || p.name === opts.project);
|
||||||
|
if (match) {
|
||||||
|
console.log(`Project context: ${match.name} (${match.id})\n`);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Gateway not available — proceed without project context
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { runPrdWizard } = await import('@mosaic/prdy');
|
||||||
|
const name =
|
||||||
|
typeof opts.init === 'string'
|
||||||
|
? opts.init
|
||||||
|
: typeof opts.update === 'string'
|
||||||
|
? opts.update
|
||||||
|
: 'untitled';
|
||||||
|
await runPrdWizard({
|
||||||
|
name,
|
||||||
|
projectPath: process.cwd(),
|
||||||
|
interactive: true,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`PRD wizard failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return cmd;
|
||||||
|
}
|
||||||
58
packages/mosaic/src/commands/select-dialog.ts
Normal file
58
packages/mosaic/src/commands/select-dialog.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* Interactive item selection. Uses @clack/prompts when TTY, falls back to numbered list.
|
||||||
|
*/
|
||||||
|
export async function selectItem<T>(
|
||||||
|
items: T[],
|
||||||
|
opts: {
|
||||||
|
message: string;
|
||||||
|
render: (item: T) => string;
|
||||||
|
emptyMessage?: string;
|
||||||
|
},
|
||||||
|
): Promise<T | undefined> {
|
||||||
|
if (items.length === 0) {
|
||||||
|
console.log(opts.emptyMessage ?? 'No items found.');
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isTTY = process.stdin.isTTY;
|
||||||
|
|
||||||
|
if (isTTY) {
|
||||||
|
try {
|
||||||
|
const { select } = await import('@clack/prompts');
|
||||||
|
const result = await select({
|
||||||
|
message: opts.message,
|
||||||
|
options: items.map((item, i) => ({
|
||||||
|
value: i,
|
||||||
|
label: opts.render(item),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (typeof result === 'symbol') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return items[result as number];
|
||||||
|
} catch {
|
||||||
|
// Fall through to non-interactive
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-interactive: display numbered list and read a number
|
||||||
|
console.log(`\n${opts.message}\n`);
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
console.log(` ${i + 1}. ${opts.render(items[i]!)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const readline = await import('node:readline');
|
||||||
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||||
|
const answer = await new Promise<string>((resolve) => rl.question('\nSelect: ', resolve));
|
||||||
|
rl.close();
|
||||||
|
|
||||||
|
const index = parseInt(answer, 10) - 1;
|
||||||
|
if (isNaN(index) || index < 0 || index >= items.length) {
|
||||||
|
console.error('Invalid selection.');
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return items[index];
|
||||||
|
}
|
||||||
29
packages/mosaic/src/commands/with-auth.ts
Normal file
29
packages/mosaic/src/commands/with-auth.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import type { AuthResult } from '../auth.js';
|
||||||
|
|
||||||
|
export interface AuthContext {
|
||||||
|
gateway: string;
|
||||||
|
session: AuthResult;
|
||||||
|
cookie: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load and validate the user's auth session.
|
||||||
|
* Exits with an error message if not signed in or session expired.
|
||||||
|
*/
|
||||||
|
export async function withAuth(gateway: string): Promise<AuthContext> {
|
||||||
|
const { loadSession, validateSession } = await import('../auth.js');
|
||||||
|
|
||||||
|
const session = loadSession(gateway);
|
||||||
|
if (!session) {
|
||||||
|
console.error('Not signed in. Run `mosaic login` first.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid = await validateSession(gateway, session.cookie);
|
||||||
|
if (!valid) {
|
||||||
|
console.error('Session expired. Run `mosaic login` again.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { gateway, session, cookie: session.cookie };
|
||||||
|
}
|
||||||
@@ -1,101 +1 @@
|
|||||||
#!/usr/bin/env node
|
export const VERSION = '0.0.0';
|
||||||
import { existsSync } from 'node:fs';
|
|
||||||
import { dirname, resolve } from 'node:path';
|
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
|
|
||||||
import { Command } from 'commander';
|
|
||||||
import { ClackPrompter } from './prompter/clack-prompter.js';
|
|
||||||
import { HeadlessPrompter } from './prompter/headless-prompter.js';
|
|
||||||
import { createConfigService } from './config/config-service.js';
|
|
||||||
import { runWizard } from './wizard.js';
|
|
||||||
import { WizardCancelledError } from './errors.js';
|
|
||||||
import { VERSION, DEFAULT_MOSAIC_HOME } from './constants.js';
|
|
||||||
import type { CommunicationStyle } from './types.js';
|
|
||||||
|
|
||||||
export { VERSION, DEFAULT_MOSAIC_HOME };
|
|
||||||
export { runWizard } from './wizard.js';
|
|
||||||
export {
|
|
||||||
checkForUpdate,
|
|
||||||
backgroundUpdateCheck,
|
|
||||||
formatUpdateNotice,
|
|
||||||
getInstalledVersion,
|
|
||||||
getLatestVersion,
|
|
||||||
semverLt,
|
|
||||||
} from './runtime/update-checker.js';
|
|
||||||
export type { UpdateCheckResult } from './runtime/update-checker.js';
|
|
||||||
export { ClackPrompter } from './prompter/clack-prompter.js';
|
|
||||||
export { HeadlessPrompter } from './prompter/headless-prompter.js';
|
|
||||||
export { createConfigService } from './config/config-service.js';
|
|
||||||
export { WizardCancelledError } from './errors.js';
|
|
||||||
|
|
||||||
const program = new Command()
|
|
||||||
.name('mosaic-wizard')
|
|
||||||
.description('Mosaic Installation Wizard')
|
|
||||||
.version(VERSION);
|
|
||||||
|
|
||||||
program
|
|
||||||
.option('--non-interactive', 'Run without prompts (uses defaults + flags)')
|
|
||||||
.option('--source-dir <path>', 'Source directory for framework files')
|
|
||||||
.option('--mosaic-home <path>', 'Target config directory', DEFAULT_MOSAIC_HOME)
|
|
||||||
// SOUL.md overrides
|
|
||||||
.option('--name <name>', 'Agent name')
|
|
||||||
.option('--role <description>', 'Agent role description')
|
|
||||||
.option('--style <style>', 'Communication style: direct|friendly|formal')
|
|
||||||
.option('--accessibility <prefs>', 'Accessibility preferences')
|
|
||||||
.option('--guardrails <rules>', 'Custom guardrails')
|
|
||||||
// USER.md overrides
|
|
||||||
.option('--user-name <name>', 'Your name')
|
|
||||||
.option('--pronouns <pronouns>', 'Your pronouns')
|
|
||||||
.option('--timezone <tz>', 'Your timezone')
|
|
||||||
.action(async (opts: Record<string, string | boolean | undefined>) => {
|
|
||||||
try {
|
|
||||||
const mosaicHome = (opts['mosaicHome'] as string) ?? DEFAULT_MOSAIC_HOME;
|
|
||||||
// Default source to the framework/ dir bundled in this npm package.
|
|
||||||
// This ensures syncFramework copies AGENTS.md, STANDARDS.md, guides/, etc.
|
|
||||||
// Falls back to mosaicHome if the bundled dir doesn't exist (shouldn't happen).
|
|
||||||
const pkgRoot = dirname(fileURLToPath(import.meta.url));
|
|
||||||
const bundledFramework = resolve(pkgRoot, '..', 'framework');
|
|
||||||
const sourceDir =
|
|
||||||
(opts['sourceDir'] as string | undefined) ??
|
|
||||||
(existsSync(bundledFramework) ? bundledFramework : mosaicHome);
|
|
||||||
|
|
||||||
const prompter = opts['nonInteractive'] ? new HeadlessPrompter() : new ClackPrompter();
|
|
||||||
|
|
||||||
const configService = createConfigService(mosaicHome, sourceDir);
|
|
||||||
|
|
||||||
const style = opts['style'] as CommunicationStyle | undefined;
|
|
||||||
|
|
||||||
await runWizard({
|
|
||||||
mosaicHome,
|
|
||||||
sourceDir,
|
|
||||||
prompter,
|
|
||||||
configService,
|
|
||||||
cliOverrides: {
|
|
||||||
soul: {
|
|
||||||
agentName: opts['name'] as string | undefined,
|
|
||||||
roleDescription: opts['role'] as string | undefined,
|
|
||||||
communicationStyle: style,
|
|
||||||
accessibility: opts['accessibility'] as string | undefined,
|
|
||||||
customGuardrails: opts['guardrails'] as string | undefined,
|
|
||||||
},
|
|
||||||
user: {
|
|
||||||
userName: opts['userName'] as string | undefined,
|
|
||||||
pronouns: opts['pronouns'] as string | undefined,
|
|
||||||
timezone: opts['timezone'] as string | undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof WizardCancelledError) {
|
|
||||||
console.log('\nWizard cancelled.');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
console.error('Wizard failed:', err);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const entryPath = process.argv[1] ? resolve(process.argv[1]) : '';
|
|
||||||
if (entryPath.length > 0 && entryPath === fileURLToPath(import.meta.url)) {
|
|
||||||
program.parse();
|
|
||||||
}
|
|
||||||
|
|||||||
468
packages/mosaic/src/tui/app.tsx
Normal file
468
packages/mosaic/src/tui/app.tsx
Normal file
@@ -0,0 +1,468 @@
|
|||||||
|
import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
|
import { Box, useApp, useInput } from 'ink';
|
||||||
|
import type { ParsedCommand } from '@mosaic/types';
|
||||||
|
import { TopBar } from './components/top-bar.js';
|
||||||
|
import { BottomBar } from './components/bottom-bar.js';
|
||||||
|
import { MessageList } from './components/message-list.js';
|
||||||
|
import { InputBar } from './components/input-bar.js';
|
||||||
|
import { Sidebar } from './components/sidebar.js';
|
||||||
|
import { SearchBar } from './components/search-bar.js';
|
||||||
|
import { useSocket } from './hooks/use-socket.js';
|
||||||
|
import { useGitInfo } from './hooks/use-git-info.js';
|
||||||
|
import { useViewport } from './hooks/use-viewport.js';
|
||||||
|
import { useAppMode } from './hooks/use-app-mode.js';
|
||||||
|
import { useConversations } from './hooks/use-conversations.js';
|
||||||
|
import { useSearch } from './hooks/use-search.js';
|
||||||
|
import { executeHelp, executeStatus, executeHistory, commandRegistry } from './commands/index.js';
|
||||||
|
import { fetchConversationMessages } from './gateway-api.js';
|
||||||
|
import { expandFileRefs, hasFileRefs, handleAttachCommand } from './file-ref.js';
|
||||||
|
|
||||||
|
export interface TuiAppProps {
|
||||||
|
gatewayUrl: string;
|
||||||
|
conversationId?: string;
|
||||||
|
sessionCookie?: string;
|
||||||
|
initialModel?: string;
|
||||||
|
initialProvider?: string;
|
||||||
|
agentId?: string;
|
||||||
|
agentName?: string;
|
||||||
|
projectId?: string;
|
||||||
|
/** CLI package version passed from the entry point (cli.ts). */
|
||||||
|
version?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TuiApp({
|
||||||
|
gatewayUrl,
|
||||||
|
conversationId,
|
||||||
|
sessionCookie,
|
||||||
|
initialModel,
|
||||||
|
initialProvider,
|
||||||
|
agentId,
|
||||||
|
agentName,
|
||||||
|
projectId: _projectId,
|
||||||
|
version = '0.0.0',
|
||||||
|
}: TuiAppProps) {
|
||||||
|
const { exit } = useApp();
|
||||||
|
const gitInfo = useGitInfo();
|
||||||
|
const appMode = useAppMode();
|
||||||
|
|
||||||
|
const socket = useSocket({
|
||||||
|
gatewayUrl,
|
||||||
|
sessionCookie,
|
||||||
|
initialConversationId: conversationId,
|
||||||
|
initialModel,
|
||||||
|
initialProvider,
|
||||||
|
agentId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const conversations = useConversations({ gatewayUrl, sessionCookie });
|
||||||
|
|
||||||
|
const viewport = useViewport({ totalItems: socket.messages.length });
|
||||||
|
|
||||||
|
const search = useSearch(socket.messages);
|
||||||
|
|
||||||
|
// Scroll to current match when it changes
|
||||||
|
const currentMatch = search.matches[search.currentMatchIndex];
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentMatch && appMode.mode === 'search') {
|
||||||
|
viewport.scrollTo(currentMatch.messageIndex);
|
||||||
|
}
|
||||||
|
}, [currentMatch, appMode.mode, viewport]);
|
||||||
|
|
||||||
|
// Compute highlighted message indices for MessageList
|
||||||
|
const highlightedMessageIndices = useMemo(() => {
|
||||||
|
if (search.matches.length === 0) return undefined;
|
||||||
|
return new Set(search.matches.map((m) => m.messageIndex));
|
||||||
|
}, [search.matches]);
|
||||||
|
|
||||||
|
const currentHighlightIndex = currentMatch?.messageIndex;
|
||||||
|
|
||||||
|
const [sidebarSelectedIndex, setSidebarSelectedIndex] = useState(0);
|
||||||
|
|
||||||
|
// Controlled input state — held here so Ctrl+C can clear it
|
||||||
|
const [tuiInput, setTuiInput] = useState('');
|
||||||
|
// Ctrl+C double-press: first press with empty input shows hint; second exits
|
||||||
|
const ctrlCPendingExit = useRef(false);
|
||||||
|
// Flag to suppress the character that ink-text-input leaks when a Ctrl+key
|
||||||
|
// combo is handled by the top-level useInput handler (e.g. Ctrl+T → 't').
|
||||||
|
const ctrlJustFired = useRef(false);
|
||||||
|
|
||||||
|
// Wrap sendMessage to expand @file references before sending
|
||||||
|
const sendMessageWithFileRefs = useCallback(
|
||||||
|
(content: string) => {
|
||||||
|
if (!hasFileRefs(content)) {
|
||||||
|
socket.sendMessage(content);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void expandFileRefs(content)
|
||||||
|
.then(({ expandedMessage, filesAttached, errors }) => {
|
||||||
|
for (const err of errors) {
|
||||||
|
socket.addSystemMessage(err);
|
||||||
|
}
|
||||||
|
if (filesAttached.length > 0) {
|
||||||
|
socket.addSystemMessage(
|
||||||
|
`📎 Attached ${filesAttached.length} file(s): ${filesAttached.join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
socket.sendMessage(expandedMessage);
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
socket.addSystemMessage(
|
||||||
|
`File expansion failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
);
|
||||||
|
// Send original message without expansion
|
||||||
|
socket.sendMessage(content);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[socket],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleLocalCommand = useCallback(
|
||||||
|
(parsed: ParsedCommand) => {
|
||||||
|
switch (parsed.command) {
|
||||||
|
case 'help':
|
||||||
|
case 'h': {
|
||||||
|
const result = executeHelp(parsed);
|
||||||
|
socket.addSystemMessage(result);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'status':
|
||||||
|
case 's': {
|
||||||
|
const result = executeStatus(parsed, {
|
||||||
|
connected: socket.connected,
|
||||||
|
model: socket.modelName,
|
||||||
|
provider: socket.providerName,
|
||||||
|
sessionId: socket.conversationId ?? null,
|
||||||
|
tokenCount: socket.tokenUsage.total,
|
||||||
|
});
|
||||||
|
socket.addSystemMessage(result);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'clear':
|
||||||
|
socket.clearMessages();
|
||||||
|
break;
|
||||||
|
case 'new':
|
||||||
|
case 'n':
|
||||||
|
void conversations
|
||||||
|
.createConversation()
|
||||||
|
.then((conv) => {
|
||||||
|
if (conv) {
|
||||||
|
socket.switchConversation(conv.id);
|
||||||
|
appMode.setMode('chat');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
socket.addSystemMessage('Failed to create new conversation.');
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'attach': {
|
||||||
|
if (!parsed.args) {
|
||||||
|
socket.addSystemMessage('Usage: /attach <file-path>');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
void handleAttachCommand(parsed.args)
|
||||||
|
.then(({ content, error }) => {
|
||||||
|
if (error) {
|
||||||
|
socket.addSystemMessage(`Attach error: ${error}`);
|
||||||
|
} else if (content) {
|
||||||
|
// Send the file content as a user message
|
||||||
|
socket.sendMessage(content);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
socket.addSystemMessage(
|
||||||
|
`Attach failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'stop':
|
||||||
|
if (socket.isStreaming && socket.socketRef.current?.connected && socket.conversationId) {
|
||||||
|
socket.socketRef.current.emit('abort', {
|
||||||
|
conversationId: socket.conversationId,
|
||||||
|
});
|
||||||
|
socket.addSystemMessage('Abort signal sent.');
|
||||||
|
} else {
|
||||||
|
socket.addSystemMessage('No active stream to stop.');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'cost': {
|
||||||
|
const u = socket.tokenUsage;
|
||||||
|
socket.addSystemMessage(
|
||||||
|
`Tokens — input: ${u.input}, output: ${u.output}, total: ${u.total}\nCost: $${u.cost.toFixed(6)}`,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'history':
|
||||||
|
case 'hist': {
|
||||||
|
void executeHistory({
|
||||||
|
conversationId: socket.conversationId,
|
||||||
|
gatewayUrl,
|
||||||
|
sessionCookie,
|
||||||
|
fetchMessages: fetchConversationMessages,
|
||||||
|
})
|
||||||
|
.then((result) => {
|
||||||
|
socket.addSystemMessage(result);
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
socket.addSystemMessage(`Failed to fetch history: ${msg}`);
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
socket.addSystemMessage(`Local command not implemented: /${parsed.command}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[socket],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleGatewayCommand = useCallback(
|
||||||
|
(parsed: ParsedCommand) => {
|
||||||
|
if (!socket.socketRef.current?.connected) {
|
||||||
|
socket.addSystemMessage('Not connected to gateway. Command cannot be executed.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
socket.socketRef.current.emit('command:execute', {
|
||||||
|
conversationId: socket.conversationId ?? '',
|
||||||
|
command: parsed.command,
|
||||||
|
args: parsed.args ?? undefined,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[socket],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSwitchConversation = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
socket.switchConversation(id);
|
||||||
|
appMode.setMode('chat');
|
||||||
|
},
|
||||||
|
[socket, appMode],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDeleteConversation = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
void conversations
|
||||||
|
.deleteConversation(id)
|
||||||
|
.then((ok) => {
|
||||||
|
if (ok && id === socket.conversationId) {
|
||||||
|
socket.clearMessages();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
},
|
||||||
|
[conversations, socket],
|
||||||
|
);
|
||||||
|
|
||||||
|
useInput((ch, key) => {
|
||||||
|
// Ctrl+C: clear input → show hint → second empty press exits
|
||||||
|
if (key.ctrl && ch === 'c') {
|
||||||
|
if (tuiInput) {
|
||||||
|
setTuiInput('');
|
||||||
|
ctrlCPendingExit.current = false;
|
||||||
|
} else if (ctrlCPendingExit.current) {
|
||||||
|
exit();
|
||||||
|
} else {
|
||||||
|
ctrlCPendingExit.current = true;
|
||||||
|
socket.addSystemMessage('Press Ctrl+C again to exit.');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Any other key resets the pending-exit flag
|
||||||
|
ctrlCPendingExit.current = false;
|
||||||
|
// Ctrl+L: toggle sidebar (refresh on open)
|
||||||
|
if (key.ctrl && ch === 'l') {
|
||||||
|
ctrlJustFired.current = true;
|
||||||
|
queueMicrotask(() => {
|
||||||
|
ctrlJustFired.current = false;
|
||||||
|
});
|
||||||
|
const willOpen = !appMode.sidebarOpen;
|
||||||
|
appMode.toggleSidebar();
|
||||||
|
if (willOpen) {
|
||||||
|
void conversations.refresh();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Ctrl+N: create new conversation and switch to it
|
||||||
|
if (key.ctrl && ch === 'n') {
|
||||||
|
ctrlJustFired.current = true;
|
||||||
|
queueMicrotask(() => {
|
||||||
|
ctrlJustFired.current = false;
|
||||||
|
});
|
||||||
|
void conversations
|
||||||
|
.createConversation()
|
||||||
|
.then((conv) => {
|
||||||
|
if (conv) {
|
||||||
|
socket.switchConversation(conv.id);
|
||||||
|
appMode.setMode('chat');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Ctrl+K: toggle search mode
|
||||||
|
if (key.ctrl && ch === 'k') {
|
||||||
|
ctrlJustFired.current = true;
|
||||||
|
queueMicrotask(() => {
|
||||||
|
ctrlJustFired.current = false;
|
||||||
|
});
|
||||||
|
if (appMode.mode === 'search') {
|
||||||
|
search.clear();
|
||||||
|
appMode.setMode('chat');
|
||||||
|
} else {
|
||||||
|
appMode.setMode('search');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Page Up / Page Down: scroll message history (only in chat mode)
|
||||||
|
if (appMode.mode === 'chat') {
|
||||||
|
if (key.pageUp) {
|
||||||
|
viewport.scrollBy(-viewport.viewportSize);
|
||||||
|
}
|
||||||
|
if (key.pageDown) {
|
||||||
|
viewport.scrollBy(viewport.viewportSize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Ctrl+T: cycle thinking level
|
||||||
|
if (key.ctrl && ch === 't') {
|
||||||
|
ctrlJustFired.current = true;
|
||||||
|
queueMicrotask(() => {
|
||||||
|
ctrlJustFired.current = false;
|
||||||
|
});
|
||||||
|
const levels = socket.availableThinkingLevels;
|
||||||
|
if (levels.length > 0) {
|
||||||
|
const currentIdx = levels.indexOf(socket.thinkingLevel);
|
||||||
|
const nextIdx = (currentIdx + 1) % levels.length;
|
||||||
|
const next = levels[nextIdx];
|
||||||
|
if (next) {
|
||||||
|
socket.setThinkingLevel(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Escape: return to chat from sidebar/search; in chat, scroll to bottom
|
||||||
|
if (key.escape) {
|
||||||
|
if (appMode.mode === 'search') {
|
||||||
|
search.clear();
|
||||||
|
appMode.setMode('chat');
|
||||||
|
} else if (appMode.mode === 'sidebar') {
|
||||||
|
appMode.setMode('chat');
|
||||||
|
} else if (appMode.mode === 'chat') {
|
||||||
|
viewport.scrollToBottom();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const inputPlaceholder =
|
||||||
|
appMode.mode === 'sidebar'
|
||||||
|
? 'focus is on sidebar… press Esc to return'
|
||||||
|
: appMode.mode === 'search'
|
||||||
|
? 'search mode… press Esc to return'
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const isSearchMode = appMode.mode === 'search';
|
||||||
|
|
||||||
|
const messageArea = (
|
||||||
|
<Box flexDirection="column" flexGrow={1}>
|
||||||
|
<MessageList
|
||||||
|
messages={socket.messages}
|
||||||
|
isStreaming={socket.isStreaming}
|
||||||
|
currentStreamText={socket.currentStreamText}
|
||||||
|
currentThinkingText={socket.currentThinkingText}
|
||||||
|
activeToolCalls={socket.activeToolCalls}
|
||||||
|
scrollOffset={viewport.scrollOffset}
|
||||||
|
viewportSize={viewport.viewportSize}
|
||||||
|
isScrolledUp={viewport.isScrolledUp}
|
||||||
|
highlightedMessageIndices={highlightedMessageIndices}
|
||||||
|
currentHighlightIndex={currentHighlightIndex}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isSearchMode && (
|
||||||
|
<SearchBar
|
||||||
|
query={search.query}
|
||||||
|
onQueryChange={search.setQuery}
|
||||||
|
totalMatches={search.totalMatches}
|
||||||
|
currentMatch={search.currentMatchIndex}
|
||||||
|
onNext={search.nextMatch}
|
||||||
|
onPrev={search.prevMatch}
|
||||||
|
onClose={() => {
|
||||||
|
search.clear();
|
||||||
|
appMode.setMode('chat');
|
||||||
|
}}
|
||||||
|
focused={isSearchMode}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<InputBar
|
||||||
|
value={tuiInput}
|
||||||
|
onChange={(val: string) => {
|
||||||
|
// Suppress the character that ink-text-input leaks when a Ctrl+key
|
||||||
|
// combo fires (e.g. Ctrl+T inserts 't'). The ctrlJustFired ref is
|
||||||
|
// set synchronously in the useInput handler and cleared via a
|
||||||
|
// microtask, so this callback sees it as still true on the same
|
||||||
|
// event-loop tick.
|
||||||
|
if (ctrlJustFired.current) {
|
||||||
|
ctrlJustFired.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTuiInput(val);
|
||||||
|
}}
|
||||||
|
onSubmit={sendMessageWithFileRefs}
|
||||||
|
onSystemMessage={socket.addSystemMessage}
|
||||||
|
onLocalCommand={handleLocalCommand}
|
||||||
|
onGatewayCommand={handleGatewayCommand}
|
||||||
|
isStreaming={socket.isStreaming}
|
||||||
|
connected={socket.connected}
|
||||||
|
focused={appMode.mode === 'chat'}
|
||||||
|
placeholder={inputPlaceholder}
|
||||||
|
allCommands={commandRegistry.getAll()}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" height="100%">
|
||||||
|
<Box marginTop={1} />
|
||||||
|
<TopBar
|
||||||
|
gatewayUrl={gatewayUrl}
|
||||||
|
version={version}
|
||||||
|
modelName={socket.modelName}
|
||||||
|
thinkingLevel={socket.thinkingLevel}
|
||||||
|
contextWindow={socket.tokenUsage.contextWindow}
|
||||||
|
agentName={agentName ?? 'default'}
|
||||||
|
connected={socket.connected}
|
||||||
|
connecting={socket.connecting}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{appMode.sidebarOpen ? (
|
||||||
|
<Box flexDirection="row" flexGrow={1}>
|
||||||
|
<Sidebar
|
||||||
|
conversations={conversations.conversations}
|
||||||
|
activeConversationId={socket.conversationId}
|
||||||
|
selectedIndex={sidebarSelectedIndex}
|
||||||
|
onSelectIndex={setSidebarSelectedIndex}
|
||||||
|
onSwitchConversation={handleSwitchConversation}
|
||||||
|
onDeleteConversation={handleDeleteConversation}
|
||||||
|
loading={conversations.loading}
|
||||||
|
focused={appMode.mode === 'sidebar'}
|
||||||
|
width={30}
|
||||||
|
/>
|
||||||
|
{messageArea}
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Box flexGrow={1}>{messageArea}</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<BottomBar
|
||||||
|
gitInfo={gitInfo}
|
||||||
|
tokenUsage={socket.tokenUsage}
|
||||||
|
connected={socket.connected}
|
||||||
|
connecting={socket.connecting}
|
||||||
|
modelName={socket.modelName}
|
||||||
|
providerName={socket.providerName}
|
||||||
|
thinkingLevel={socket.thinkingLevel}
|
||||||
|
conversationId={socket.conversationId}
|
||||||
|
routingDecision={socket.routingDecision}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
348
packages/mosaic/src/tui/commands/commands.integration.spec.ts
Normal file
348
packages/mosaic/src/tui/commands/commands.integration.spec.ts
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
/**
|
||||||
|
* Integration tests for TUI command parsing + registry (P8-019)
|
||||||
|
*
|
||||||
|
* Covers:
|
||||||
|
* - parseSlashCommand() + commandRegistry.find() round-trip for all aliases
|
||||||
|
* - /help, /stop, /cost, /status resolve to 'local' execution
|
||||||
|
* - Unknown commands return null from find()
|
||||||
|
* - Alias resolution: /h → help, /m → model, /n → new, etc.
|
||||||
|
* - filterCommands prefix filtering
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { parseSlashCommand } from './parse.js';
|
||||||
|
import { CommandRegistry } from './registry.js';
|
||||||
|
import type { CommandDef } from '@mosaic/types';
|
||||||
|
|
||||||
|
// ─── Parse + Registry Round-trip ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('parseSlashCommand + CommandRegistry — integration', () => {
|
||||||
|
let registry: CommandRegistry;
|
||||||
|
|
||||||
|
// Gateway-style commands to simulate a live manifest
|
||||||
|
const gatewayCommands: CommandDef[] = [
|
||||||
|
{
|
||||||
|
name: 'model',
|
||||||
|
description: 'Switch the active model',
|
||||||
|
aliases: ['m'],
|
||||||
|
args: [{ name: 'model-name', type: 'string', optional: false, description: 'Model name' }],
|
||||||
|
scope: 'core',
|
||||||
|
execution: 'socket',
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'thinking',
|
||||||
|
description: 'Set thinking level',
|
||||||
|
aliases: ['t'],
|
||||||
|
args: [
|
||||||
|
{
|
||||||
|
name: 'level',
|
||||||
|
type: 'enum',
|
||||||
|
optional: false,
|
||||||
|
values: ['none', 'low', 'medium', 'high', 'auto'],
|
||||||
|
description: 'Thinking level',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
scope: 'core',
|
||||||
|
execution: 'socket',
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'new',
|
||||||
|
description: 'Start a new conversation',
|
||||||
|
aliases: ['n'],
|
||||||
|
scope: 'core',
|
||||||
|
execution: 'socket',
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'agent',
|
||||||
|
description: 'Switch or list available agents',
|
||||||
|
aliases: ['a'],
|
||||||
|
args: [{ name: 'args', type: 'string', optional: true, description: 'list or <agent-id>' }],
|
||||||
|
scope: 'agent',
|
||||||
|
execution: 'socket',
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'preferences',
|
||||||
|
description: 'View or set user preferences',
|
||||||
|
aliases: ['pref'],
|
||||||
|
args: [
|
||||||
|
{
|
||||||
|
name: 'action',
|
||||||
|
type: 'enum',
|
||||||
|
optional: true,
|
||||||
|
values: ['show', 'set', 'reset'],
|
||||||
|
description: 'Action',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
scope: 'core',
|
||||||
|
execution: 'rest',
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'gc',
|
||||||
|
description: 'Trigger garbage collection sweep',
|
||||||
|
aliases: [],
|
||||||
|
scope: 'core',
|
||||||
|
execution: 'socket',
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'mission',
|
||||||
|
description: 'View or set active mission',
|
||||||
|
aliases: [],
|
||||||
|
args: [{ name: 'args', type: 'string', optional: true, description: 'status | set <id>' }],
|
||||||
|
scope: 'agent',
|
||||||
|
execution: 'socket',
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
registry = new CommandRegistry();
|
||||||
|
registry.updateManifest({ version: 1, commands: gatewayCommands, skills: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── parseSlashCommand tests ──
|
||||||
|
|
||||||
|
it('returns null for non-slash input', () => {
|
||||||
|
expect(parseSlashCommand('hello world')).toBeNull();
|
||||||
|
expect(parseSlashCommand('')).toBeNull();
|
||||||
|
expect(parseSlashCommand('model')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses "/model claude-3-opus" → command=model args=claude-3-opus', () => {
|
||||||
|
const parsed = parseSlashCommand('/model claude-3-opus');
|
||||||
|
expect(parsed).not.toBeNull();
|
||||||
|
expect(parsed!.command).toBe('model');
|
||||||
|
expect(parsed!.args).toBe('claude-3-opus');
|
||||||
|
expect(parsed!.raw).toBe('/model claude-3-opus');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses "/gc" with no args → command=gc args=null', () => {
|
||||||
|
const parsed = parseSlashCommand('/gc');
|
||||||
|
expect(parsed).not.toBeNull();
|
||||||
|
expect(parsed!.command).toBe('gc');
|
||||||
|
expect(parsed!.args).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses "/system you are a helpful assistant" → args contains full text', () => {
|
||||||
|
const parsed = parseSlashCommand('/system you are a helpful assistant');
|
||||||
|
expect(parsed!.command).toBe('system');
|
||||||
|
expect(parsed!.args).toBe('you are a helpful assistant');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses "/help" → command=help args=null', () => {
|
||||||
|
const parsed = parseSlashCommand('/help');
|
||||||
|
expect(parsed!.command).toBe('help');
|
||||||
|
expect(parsed!.args).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Round-trip: parse then find ──
|
||||||
|
|
||||||
|
it('round-trip: /m → resolves to "model" command via alias', () => {
|
||||||
|
const parsed = parseSlashCommand('/m claude-3-haiku');
|
||||||
|
expect(parsed).not.toBeNull();
|
||||||
|
const cmd = registry.find(parsed!.command);
|
||||||
|
expect(cmd).not.toBeNull();
|
||||||
|
// /m → model (alias map in registry)
|
||||||
|
expect(cmd!.name === 'model' || cmd!.aliases.includes('m')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trip: /h → resolves to "help" (local command)', () => {
|
||||||
|
const parsed = parseSlashCommand('/h');
|
||||||
|
expect(parsed).not.toBeNull();
|
||||||
|
const cmd = registry.find(parsed!.command);
|
||||||
|
expect(cmd).not.toBeNull();
|
||||||
|
expect(cmd!.name === 'help' || cmd!.aliases.includes('h')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trip: /n → resolves to "new" via gateway manifest', () => {
|
||||||
|
const parsed = parseSlashCommand('/n');
|
||||||
|
expect(parsed).not.toBeNull();
|
||||||
|
const cmd = registry.find(parsed!.command);
|
||||||
|
expect(cmd).not.toBeNull();
|
||||||
|
expect(cmd!.name === 'new' || cmd!.aliases.includes('n')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trip: /a → resolves to "agent" via gateway manifest', () => {
|
||||||
|
const parsed = parseSlashCommand('/a list');
|
||||||
|
expect(parsed).not.toBeNull();
|
||||||
|
const cmd = registry.find(parsed!.command);
|
||||||
|
expect(cmd).not.toBeNull();
|
||||||
|
expect(cmd!.name === 'agent' || cmd!.aliases.includes('a')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trip: /pref → resolves to "preferences" via alias', () => {
|
||||||
|
const parsed = parseSlashCommand('/pref show');
|
||||||
|
expect(parsed).not.toBeNull();
|
||||||
|
const cmd = registry.find(parsed!.command);
|
||||||
|
expect(cmd).not.toBeNull();
|
||||||
|
expect(cmd!.name === 'preferences' || cmd!.aliases.includes('pref')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trip: /t → resolves to "thinking" via alias', () => {
|
||||||
|
const parsed = parseSlashCommand('/t high');
|
||||||
|
expect(parsed).not.toBeNull();
|
||||||
|
const cmd = registry.find(parsed!.command);
|
||||||
|
expect(cmd).not.toBeNull();
|
||||||
|
expect(cmd!.name === 'thinking' || cmd!.aliases.includes('t')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Local commands resolve to 'local' execution ──
|
||||||
|
|
||||||
|
it('/help resolves to local execution', () => {
|
||||||
|
const cmd = registry.find('help');
|
||||||
|
expect(cmd).not.toBeNull();
|
||||||
|
expect(cmd!.execution).toBe('local');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('/stop resolves to local execution', () => {
|
||||||
|
const cmd = registry.find('stop');
|
||||||
|
expect(cmd).not.toBeNull();
|
||||||
|
expect(cmd!.execution).toBe('local');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('/cost resolves to local execution', () => {
|
||||||
|
const cmd = registry.find('cost');
|
||||||
|
expect(cmd).not.toBeNull();
|
||||||
|
expect(cmd!.execution).toBe('local');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('/status resolves to local execution (TUI local override)', () => {
|
||||||
|
const cmd = registry.find('status');
|
||||||
|
expect(cmd).not.toBeNull();
|
||||||
|
// status is 'local' in the TUI registry (local takes precedence over gateway)
|
||||||
|
expect(cmd!.execution).toBe('local');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Unknown commands return null ──
|
||||||
|
|
||||||
|
it('find() returns null for unknown command', () => {
|
||||||
|
expect(registry.find('nonexistent')).toBeNull();
|
||||||
|
expect(registry.find('xyz')).toBeNull();
|
||||||
|
expect(registry.find('')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('find() returns null when no gateway manifest and command not local', () => {
|
||||||
|
const emptyRegistry = new CommandRegistry();
|
||||||
|
expect(emptyRegistry.find('model')).toBeNull();
|
||||||
|
expect(emptyRegistry.find('gc')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── getAll returns combined local + gateway ──
|
||||||
|
|
||||||
|
it('getAll() includes both local and gateway commands', () => {
|
||||||
|
const all = registry.getAll();
|
||||||
|
const names = all.map((c) => c.name);
|
||||||
|
// Local commands
|
||||||
|
expect(names).toContain('help');
|
||||||
|
expect(names).toContain('stop');
|
||||||
|
expect(names).toContain('cost');
|
||||||
|
expect(names).toContain('status');
|
||||||
|
// Gateway commands
|
||||||
|
expect(names).toContain('model');
|
||||||
|
expect(names).toContain('gc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getLocalCommands() returns only local commands', () => {
|
||||||
|
const local = registry.getLocalCommands();
|
||||||
|
expect(local.every((c) => c.execution === 'local')).toBe(true);
|
||||||
|
expect(local.some((c) => c.name === 'help')).toBe(true);
|
||||||
|
expect(local.some((c) => c.name === 'stop')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── filterCommands (autocomplete) ────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('filterCommands (from CommandAutocomplete)', () => {
|
||||||
|
// Import inline since filterCommands is not exported — replicate the logic here
|
||||||
|
function filterCommands(commands: CommandDef[], query: string): CommandDef[] {
|
||||||
|
if (!query) return commands;
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
return commands.filter(
|
||||||
|
(c) =>
|
||||||
|
c.name.includes(q) ||
|
||||||
|
c.aliases.some((a) => a.includes(q)) ||
|
||||||
|
c.description.toLowerCase().includes(q),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const commands: CommandDef[] = [
|
||||||
|
{
|
||||||
|
name: 'model',
|
||||||
|
description: 'Switch the active model',
|
||||||
|
aliases: ['m'],
|
||||||
|
scope: 'core',
|
||||||
|
execution: 'socket',
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'mission',
|
||||||
|
description: 'View or set active mission',
|
||||||
|
aliases: [],
|
||||||
|
scope: 'agent',
|
||||||
|
execution: 'socket',
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'help',
|
||||||
|
description: 'Show available commands',
|
||||||
|
aliases: ['h'],
|
||||||
|
scope: 'core',
|
||||||
|
execution: 'local',
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'gc',
|
||||||
|
description: 'Trigger garbage collection sweep',
|
||||||
|
aliases: [],
|
||||||
|
scope: 'core',
|
||||||
|
execution: 'socket',
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
it('returns all commands when query is empty', () => {
|
||||||
|
expect(filterCommands(commands, '')).toHaveLength(commands.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters by name prefix "mi" → mission only (not model, as "mi" not in model name or aliases)', () => {
|
||||||
|
const result = filterCommands(commands, 'mi');
|
||||||
|
const names = result.map((c) => c.name);
|
||||||
|
expect(names).toContain('mission');
|
||||||
|
expect(names).not.toContain('gc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters by name prefix "mo" → model only', () => {
|
||||||
|
const result = filterCommands(commands, 'mo');
|
||||||
|
const names = result.map((c) => c.name);
|
||||||
|
expect(names).toContain('model');
|
||||||
|
expect(names).not.toContain('mission');
|
||||||
|
expect(names).not.toContain('gc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters by exact name "gc" → gc only', () => {
|
||||||
|
const result = filterCommands(commands, 'gc');
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0]!.name).toBe('gc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters by alias "h" → help', () => {
|
||||||
|
const result = filterCommands(commands, 'h');
|
||||||
|
const names = result.map((c) => c.name);
|
||||||
|
expect(names).toContain('help');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters by description keyword "switch" → model', () => {
|
||||||
|
const result = filterCommands(commands, 'switch');
|
||||||
|
const names = result.map((c) => c.name);
|
||||||
|
expect(names).toContain('model');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array when no commands match', () => {
|
||||||
|
const result = filterCommands(commands, 'zzznotfound');
|
||||||
|
expect(result).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
7
packages/mosaic/src/tui/commands/index.ts
Normal file
7
packages/mosaic/src/tui/commands/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export { parseSlashCommand } from './parse.js';
|
||||||
|
export { commandRegistry, CommandRegistry } from './registry.js';
|
||||||
|
export { executeHelp } from './local/help.js';
|
||||||
|
export { executeStatus } from './local/status.js';
|
||||||
|
export type { StatusContext } from './local/status.js';
|
||||||
|
export { executeHistory } from './local/history.js';
|
||||||
|
export type { HistoryContext } from './local/history.js';
|
||||||
19
packages/mosaic/src/tui/commands/local/help.ts
Normal file
19
packages/mosaic/src/tui/commands/local/help.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type { ParsedCommand } from '@mosaic/types';
|
||||||
|
import { commandRegistry } from '../registry.js';
|
||||||
|
|
||||||
|
export function executeHelp(_parsed: ParsedCommand): string {
|
||||||
|
const commands = commandRegistry.getAll();
|
||||||
|
const lines = ['Available commands:', ''];
|
||||||
|
|
||||||
|
for (const cmd of commands) {
|
||||||
|
const aliases =
|
||||||
|
cmd.aliases.length > 0 ? ` (${cmd.aliases.map((a) => `/${a}`).join(', ')})` : '';
|
||||||
|
const argsStr =
|
||||||
|
cmd.args && cmd.args.length > 0
|
||||||
|
? ' ' + cmd.args.map((a) => (a.optional ? `[${a.name}]` : `<${a.name}>`)).join(' ')
|
||||||
|
: '';
|
||||||
|
lines.push(` /${cmd.name}${argsStr}${aliases} — ${cmd.description}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n').trimEnd();
|
||||||
|
}
|
||||||
53
packages/mosaic/src/tui/commands/local/history.ts
Normal file
53
packages/mosaic/src/tui/commands/local/history.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import type { ConversationMessage } from '../../gateway-api.js';
|
||||||
|
|
||||||
|
const CONTEXT_WINDOW = 200_000;
|
||||||
|
const CHARS_PER_TOKEN = 4;
|
||||||
|
|
||||||
|
function estimateTokens(messages: ConversationMessage[]): number {
|
||||||
|
const totalChars = messages.reduce((sum, m) => sum + (m.content?.length ?? 0), 0);
|
||||||
|
return Math.round(totalChars / CHARS_PER_TOKEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HistoryContext {
|
||||||
|
conversationId: string | undefined;
|
||||||
|
conversationTitle?: string | null;
|
||||||
|
gatewayUrl: string;
|
||||||
|
sessionCookie: string | undefined;
|
||||||
|
fetchMessages: (
|
||||||
|
gatewayUrl: string,
|
||||||
|
sessionCookie: string,
|
||||||
|
conversationId: string,
|
||||||
|
) => Promise<ConversationMessage[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executeHistory(ctx: HistoryContext): Promise<string> {
|
||||||
|
const { conversationId, conversationTitle, gatewayUrl, sessionCookie, fetchMessages } = ctx;
|
||||||
|
|
||||||
|
if (!conversationId) {
|
||||||
|
return 'No active conversation.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sessionCookie) {
|
||||||
|
return 'Not authenticated — cannot fetch conversation messages.';
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = await fetchMessages(gatewayUrl, sessionCookie, conversationId);
|
||||||
|
|
||||||
|
const userMessages = messages.filter((m) => m.role === 'user').length;
|
||||||
|
const assistantMessages = messages.filter((m) => m.role === 'assistant').length;
|
||||||
|
const totalMessages = messages.length;
|
||||||
|
|
||||||
|
const estimatedTokens = estimateTokens(messages);
|
||||||
|
const contextPercent = Math.round((estimatedTokens / CONTEXT_WINDOW) * 100);
|
||||||
|
|
||||||
|
const label = conversationTitle ?? conversationId;
|
||||||
|
|
||||||
|
const lines = [
|
||||||
|
`Conversation: ${label}`,
|
||||||
|
`Messages: ${totalMessages} (${userMessages} user, ${assistantMessages} assistant)`,
|
||||||
|
`Estimated tokens: ~${estimatedTokens.toLocaleString()}`,
|
||||||
|
`Context usage: ~${contextPercent}% of ${(CONTEXT_WINDOW / 1000).toFixed(0)}K`,
|
||||||
|
];
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
20
packages/mosaic/src/tui/commands/local/status.ts
Normal file
20
packages/mosaic/src/tui/commands/local/status.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import type { ParsedCommand } from '@mosaic/types';
|
||||||
|
|
||||||
|
export interface StatusContext {
|
||||||
|
connected: boolean;
|
||||||
|
model: string | null;
|
||||||
|
provider: string | null;
|
||||||
|
sessionId: string | null;
|
||||||
|
tokenCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function executeStatus(_parsed: ParsedCommand, ctx: StatusContext): string {
|
||||||
|
const lines = [
|
||||||
|
`Connection: ${ctx.connected ? 'connected' : 'disconnected'}`,
|
||||||
|
`Model: ${ctx.model ?? 'unknown'}`,
|
||||||
|
`Provider: ${ctx.provider ?? 'unknown'}`,
|
||||||
|
`Session: ${ctx.sessionId ?? 'none'}`,
|
||||||
|
`Tokens (session): ${ctx.tokenCount}`,
|
||||||
|
];
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
11
packages/mosaic/src/tui/commands/parse.ts
Normal file
11
packages/mosaic/src/tui/commands/parse.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import type { ParsedCommand } from '@mosaic/types';
|
||||||
|
|
||||||
|
export function parseSlashCommand(input: string): ParsedCommand | null {
|
||||||
|
const match = input.match(/^\/([a-z][a-z0-9:_-]*)\s*(.*)?$/i);
|
||||||
|
if (!match) return null;
|
||||||
|
return {
|
||||||
|
command: match[1]!,
|
||||||
|
args: match[2]?.trim() || null,
|
||||||
|
raw: input,
|
||||||
|
};
|
||||||
|
}
|
||||||
137
packages/mosaic/src/tui/commands/registry.ts
Normal file
137
packages/mosaic/src/tui/commands/registry.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import type { CommandDef, CommandManifest } from '@mosaic/types';
|
||||||
|
|
||||||
|
// Local-only commands (work even when gateway is disconnected)
|
||||||
|
const LOCAL_COMMANDS: CommandDef[] = [
|
||||||
|
{
|
||||||
|
name: 'help',
|
||||||
|
description: 'Show available commands',
|
||||||
|
aliases: ['h'],
|
||||||
|
args: undefined,
|
||||||
|
execution: 'local',
|
||||||
|
available: true,
|
||||||
|
scope: 'core',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'stop',
|
||||||
|
description: 'Cancel current streaming response',
|
||||||
|
aliases: [],
|
||||||
|
args: undefined,
|
||||||
|
execution: 'local',
|
||||||
|
available: true,
|
||||||
|
scope: 'core',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'cost',
|
||||||
|
description: 'Show token usage and cost for current session',
|
||||||
|
aliases: [],
|
||||||
|
args: undefined,
|
||||||
|
execution: 'local',
|
||||||
|
available: true,
|
||||||
|
scope: 'core',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'status',
|
||||||
|
description: 'Show connection and session status',
|
||||||
|
aliases: ['s'],
|
||||||
|
args: undefined,
|
||||||
|
execution: 'local',
|
||||||
|
available: true,
|
||||||
|
scope: 'core',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'history',
|
||||||
|
description: 'Show conversation message count and context usage',
|
||||||
|
aliases: ['hist'],
|
||||||
|
args: undefined,
|
||||||
|
execution: 'local',
|
||||||
|
available: true,
|
||||||
|
scope: 'core',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'clear',
|
||||||
|
description: 'Clear the current conversation display',
|
||||||
|
aliases: [],
|
||||||
|
args: undefined,
|
||||||
|
execution: 'local',
|
||||||
|
available: true,
|
||||||
|
scope: 'core',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'attach',
|
||||||
|
description: 'Attach a file to the next message (@file syntax also works inline)',
|
||||||
|
aliases: [],
|
||||||
|
args: [
|
||||||
|
{
|
||||||
|
name: 'path',
|
||||||
|
type: 'string' as const,
|
||||||
|
optional: false,
|
||||||
|
description: 'File path to attach',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
execution: 'local',
|
||||||
|
available: true,
|
||||||
|
scope: 'core',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'new',
|
||||||
|
description: 'Start a new conversation',
|
||||||
|
aliases: ['n'],
|
||||||
|
args: undefined,
|
||||||
|
execution: 'local',
|
||||||
|
available: true,
|
||||||
|
scope: 'core',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const ALIASES: Record<string, string> = {
|
||||||
|
m: 'model',
|
||||||
|
t: 'thinking',
|
||||||
|
a: 'agent',
|
||||||
|
s: 'status',
|
||||||
|
h: 'help',
|
||||||
|
hist: 'history',
|
||||||
|
pref: 'preferences',
|
||||||
|
};
|
||||||
|
|
||||||
|
export class CommandRegistry {
|
||||||
|
private gatewayManifest: CommandManifest | null = null;
|
||||||
|
|
||||||
|
updateManifest(manifest: CommandManifest): void {
|
||||||
|
this.gatewayManifest = manifest;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveAlias(command: string): string {
|
||||||
|
return ALIASES[command] ?? command;
|
||||||
|
}
|
||||||
|
|
||||||
|
find(command: string): CommandDef | null {
|
||||||
|
const resolved = this.resolveAlias(command);
|
||||||
|
// Search local first, then gateway manifest
|
||||||
|
const local = LOCAL_COMMANDS.find((c) => c.name === resolved || c.aliases.includes(resolved));
|
||||||
|
if (local) return local;
|
||||||
|
if (this.gatewayManifest) {
|
||||||
|
return (
|
||||||
|
this.gatewayManifest.commands.find(
|
||||||
|
(c) => c.name === resolved || c.aliases.includes(resolved),
|
||||||
|
) ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getAll(): CommandDef[] {
|
||||||
|
const gateway = this.gatewayManifest?.commands ?? [];
|
||||||
|
// Local commands take precedence; deduplicate gateway commands that share
|
||||||
|
// a name with a local command to avoid duplicate React keys and confusing
|
||||||
|
// autocomplete entries.
|
||||||
|
const localNames = new Set(LOCAL_COMMANDS.map((c) => c.name));
|
||||||
|
const dedupedGateway = gateway.filter((c) => !localNames.has(c.name));
|
||||||
|
return [...LOCAL_COMMANDS, ...dedupedGateway];
|
||||||
|
}
|
||||||
|
|
||||||
|
getLocalCommands(): CommandDef[] {
|
||||||
|
return LOCAL_COMMANDS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const commandRegistry = new CommandRegistry();
|
||||||
138
packages/mosaic/src/tui/components/bottom-bar.tsx
Normal file
138
packages/mosaic/src/tui/components/bottom-bar.tsx
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Box, Text } from 'ink';
|
||||||
|
import type { RoutingDecisionInfo } from '@mosaic/types';
|
||||||
|
import type { TokenUsage } from '../hooks/use-socket.js';
|
||||||
|
import type { GitInfo } from '../hooks/use-git-info.js';
|
||||||
|
|
||||||
|
export interface BottomBarProps {
|
||||||
|
gitInfo: GitInfo;
|
||||||
|
tokenUsage: TokenUsage;
|
||||||
|
connected: boolean;
|
||||||
|
connecting: boolean;
|
||||||
|
modelName: string | null;
|
||||||
|
providerName: string | null;
|
||||||
|
thinkingLevel: string;
|
||||||
|
conversationId: string | undefined;
|
||||||
|
/** Routing decision info for transparency display (M4-008) */
|
||||||
|
routingDecision?: RoutingDecisionInfo | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTokens(n: number): string {
|
||||||
|
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||||
|
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}k`;
|
||||||
|
return String(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compact the cwd — replace home with ~ */
|
||||||
|
function compactCwd(cwd: string): string {
|
||||||
|
const home = process.env['HOME'] ?? '';
|
||||||
|
if (home && cwd.startsWith(home)) {
|
||||||
|
return '~' + cwd.slice(home.length);
|
||||||
|
}
|
||||||
|
return cwd;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BottomBar({
|
||||||
|
gitInfo,
|
||||||
|
tokenUsage,
|
||||||
|
connected,
|
||||||
|
connecting,
|
||||||
|
modelName,
|
||||||
|
providerName,
|
||||||
|
thinkingLevel,
|
||||||
|
conversationId,
|
||||||
|
routingDecision,
|
||||||
|
}: BottomBarProps) {
|
||||||
|
const gatewayStatus = connected ? 'Connected' : connecting ? 'Connecting…' : 'Disconnected';
|
||||||
|
const gatewayColor = connected ? 'green' : connecting ? 'yellow' : 'red';
|
||||||
|
|
||||||
|
const hasTokens = tokenUsage.total > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" paddingX={0} marginTop={0}>
|
||||||
|
{/* Line 0: keybinding hints */}
|
||||||
|
<Box>
|
||||||
|
<Text dimColor>^L sidebar · ^N new · ^K search · ^T thinking · PgUp/Dn scroll</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Line 1: blank ····· Gateway: Status */}
|
||||||
|
<Box justifyContent="space-between">
|
||||||
|
<Box />
|
||||||
|
<Box>
|
||||||
|
<Text dimColor>Gateway: </Text>
|
||||||
|
<Text color={gatewayColor}>{gatewayStatus}</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Line 2: cwd (branch) ····· Session: id */}
|
||||||
|
<Box justifyContent="space-between">
|
||||||
|
<Box>
|
||||||
|
<Text dimColor>{compactCwd(gitInfo.cwd)}</Text>
|
||||||
|
{gitInfo.branch && <Text dimColor> ({gitInfo.branch})</Text>}
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text dimColor>
|
||||||
|
{conversationId ? `Session: ${conversationId.slice(0, 8)}` : 'No session'}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Line 3: token stats ····· (provider) model */}
|
||||||
|
<Box justifyContent="space-between" minHeight={1}>
|
||||||
|
<Box>
|
||||||
|
{hasTokens ? (
|
||||||
|
<>
|
||||||
|
<Text dimColor>↑{formatTokens(tokenUsage.input)}</Text>
|
||||||
|
<Text dimColor>{' '}</Text>
|
||||||
|
<Text dimColor>↓{formatTokens(tokenUsage.output)}</Text>
|
||||||
|
{tokenUsage.cacheRead > 0 && (
|
||||||
|
<>
|
||||||
|
<Text dimColor>{' '}</Text>
|
||||||
|
<Text dimColor>R{formatTokens(tokenUsage.cacheRead)}</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{tokenUsage.cacheWrite > 0 && (
|
||||||
|
<>
|
||||||
|
<Text dimColor>{' '}</Text>
|
||||||
|
<Text dimColor>W{formatTokens(tokenUsage.cacheWrite)}</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{tokenUsage.cost > 0 && (
|
||||||
|
<>
|
||||||
|
<Text dimColor>{' '}</Text>
|
||||||
|
<Text dimColor>${tokenUsage.cost.toFixed(3)}</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{tokenUsage.contextPercent > 0 && (
|
||||||
|
<>
|
||||||
|
<Text dimColor>{' '}</Text>
|
||||||
|
<Text dimColor>
|
||||||
|
{tokenUsage.contextPercent.toFixed(1)}%/{formatTokens(tokenUsage.contextWindow)}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Text dimColor>↑0 ↓0 $0.000</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text dimColor>
|
||||||
|
{providerName ? `(${providerName}) ` : ''}
|
||||||
|
{modelName ?? 'awaiting model'}
|
||||||
|
{thinkingLevel !== 'off' ? ` • ${thinkingLevel}` : ''}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Line 4: routing transparency (M4-008) — only shown when a routing decision is available */}
|
||||||
|
{routingDecision && (
|
||||||
|
<Box>
|
||||||
|
<Text dimColor>
|
||||||
|
Routed: {routingDecision.model} ({routingDecision.reason})
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
packages/mosaic/src/tui/components/command-autocomplete.tsx
Normal file
66
packages/mosaic/src/tui/components/command-autocomplete.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Box, Text } from 'ink';
|
||||||
|
import type { CommandDef, CommandArgDef } from '@mosaic/types';
|
||||||
|
|
||||||
|
interface CommandAutocompleteProps {
|
||||||
|
commands: CommandDef[];
|
||||||
|
selectedIndex: number;
|
||||||
|
inputValue: string; // the current input after '/'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommandAutocomplete({
|
||||||
|
commands,
|
||||||
|
selectedIndex,
|
||||||
|
inputValue,
|
||||||
|
}: CommandAutocompleteProps) {
|
||||||
|
if (commands.length === 0) return null;
|
||||||
|
|
||||||
|
// Filter by inputValue prefix/fuzzy match
|
||||||
|
const query = inputValue.startsWith('/') ? inputValue.slice(1) : inputValue;
|
||||||
|
const filtered = filterCommands(commands, query);
|
||||||
|
|
||||||
|
if (filtered.length === 0) return null;
|
||||||
|
|
||||||
|
const clampedIndex = Math.min(selectedIndex, filtered.length - 1);
|
||||||
|
const selected = filtered[clampedIndex];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" borderStyle="round" borderColor="gray" paddingX={1}>
|
||||||
|
{filtered.slice(0, 8).map((cmd, i) => (
|
||||||
|
<Box key={`${cmd.execution}-${cmd.name}`}>
|
||||||
|
<Text color={i === clampedIndex ? 'cyan' : 'white'} bold={i === clampedIndex}>
|
||||||
|
{i === clampedIndex ? '▶ ' : ' '}/{cmd.name}
|
||||||
|
</Text>
|
||||||
|
{cmd.aliases.length > 0 && (
|
||||||
|
<Text color="gray"> ({cmd.aliases.map((a) => `/${a}`).join(', ')})</Text>
|
||||||
|
)}
|
||||||
|
<Text color="gray"> — {cmd.description}</Text>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
{selected && selected.args && selected.args.length > 0 && (
|
||||||
|
<Box marginTop={1} borderStyle="single" borderColor="gray" paddingX={1}>
|
||||||
|
<Text color="yellow">
|
||||||
|
/{selected.name} {getArgHint(selected.args)}
|
||||||
|
</Text>
|
||||||
|
<Text color="gray"> — {selected.description}</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterCommands(commands: CommandDef[], query: string): CommandDef[] {
|
||||||
|
if (!query) return commands;
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
return commands.filter(
|
||||||
|
(c) =>
|
||||||
|
c.name.includes(q) ||
|
||||||
|
c.aliases.some((a) => a.includes(q)) ||
|
||||||
|
c.description.toLowerCase().includes(q),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getArgHint(args: CommandArgDef[]): string {
|
||||||
|
if (!args || args.length === 0) return '';
|
||||||
|
return args.map((a) => (a.optional ? `[${a.name}]` : `<${a.name}>`)).join(' ');
|
||||||
|
}
|
||||||
225
packages/mosaic/src/tui/components/input-bar.tsx
Normal file
225
packages/mosaic/src/tui/components/input-bar.tsx
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
import React, { useCallback } from 'react';
|
||||||
|
import { Box, Text, useInput } from 'ink';
|
||||||
|
import TextInput from 'ink-text-input';
|
||||||
|
import type { ParsedCommand, CommandDef } from '@mosaic/types';
|
||||||
|
import { parseSlashCommand, commandRegistry } from '../commands/index.js';
|
||||||
|
import { CommandAutocomplete } from './command-autocomplete.js';
|
||||||
|
import { useInputHistory } from '../hooks/use-input-history.js';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
export interface InputBarProps {
|
||||||
|
/** Controlled input value — caller owns the state */
|
||||||
|
value: string;
|
||||||
|
onChange: (val: string) => void;
|
||||||
|
onSubmit: (value: string) => void;
|
||||||
|
onSystemMessage?: (message: string) => void;
|
||||||
|
onLocalCommand?: (parsed: ParsedCommand) => void;
|
||||||
|
onGatewayCommand?: (parsed: ParsedCommand) => void;
|
||||||
|
isStreaming: boolean;
|
||||||
|
connected: boolean;
|
||||||
|
/** Whether this input bar is focused/active (default true). When false,
|
||||||
|
* keyboard input is not captured — e.g. when the sidebar has focus. */
|
||||||
|
focused?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
allCommands?: CommandDef[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InputBar({
|
||||||
|
value: input,
|
||||||
|
onChange: setInput,
|
||||||
|
onSubmit,
|
||||||
|
onSystemMessage,
|
||||||
|
onLocalCommand,
|
||||||
|
onGatewayCommand,
|
||||||
|
isStreaming,
|
||||||
|
connected,
|
||||||
|
focused = true,
|
||||||
|
placeholder: placeholderOverride,
|
||||||
|
allCommands,
|
||||||
|
}: InputBarProps) {
|
||||||
|
const [showAutocomplete, setShowAutocomplete] = useState(false);
|
||||||
|
const [autocompleteIndex, setAutocompleteIndex] = useState(0);
|
||||||
|
|
||||||
|
const { addToHistory, navigateUp, navigateDown } = useInputHistory();
|
||||||
|
|
||||||
|
// Determine which commands to show in autocomplete
|
||||||
|
const availableCommands = allCommands ?? commandRegistry.getAll();
|
||||||
|
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
setInput(value);
|
||||||
|
if (value.startsWith('/')) {
|
||||||
|
setShowAutocomplete(true);
|
||||||
|
setAutocompleteIndex(0);
|
||||||
|
} else {
|
||||||
|
setShowAutocomplete(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setInput],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
if (!value.trim() || isStreaming || !connected) return;
|
||||||
|
|
||||||
|
const trimmed = value.trim();
|
||||||
|
|
||||||
|
addToHistory(trimmed);
|
||||||
|
setShowAutocomplete(false);
|
||||||
|
setAutocompleteIndex(0);
|
||||||
|
|
||||||
|
if (trimmed.startsWith('/')) {
|
||||||
|
const parsed = parseSlashCommand(trimmed);
|
||||||
|
if (!parsed) {
|
||||||
|
// Bare "/" or malformed — ignore silently (autocomplete handles discovery)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const def = commandRegistry.find(parsed.command);
|
||||||
|
if (!def) {
|
||||||
|
onSystemMessage?.(
|
||||||
|
`Unknown command: /${parsed.command}. Type /help for available commands.`,
|
||||||
|
);
|
||||||
|
setInput('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (def.execution === 'local') {
|
||||||
|
onLocalCommand?.(parsed);
|
||||||
|
setInput('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Gateway-executed commands
|
||||||
|
onGatewayCommand?.(parsed);
|
||||||
|
setInput('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSubmit(value);
|
||||||
|
setInput('');
|
||||||
|
},
|
||||||
|
[
|
||||||
|
onSubmit,
|
||||||
|
onSystemMessage,
|
||||||
|
onLocalCommand,
|
||||||
|
onGatewayCommand,
|
||||||
|
isStreaming,
|
||||||
|
connected,
|
||||||
|
addToHistory,
|
||||||
|
setInput,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle Tab: fill in selected autocomplete command
|
||||||
|
const fillAutocompleteSelection = useCallback(() => {
|
||||||
|
if (!showAutocomplete) return false;
|
||||||
|
const query = input.startsWith('/') ? input.slice(1) : input;
|
||||||
|
const filtered = availableCommands.filter(
|
||||||
|
(c) =>
|
||||||
|
!query ||
|
||||||
|
c.name.includes(query.toLowerCase()) ||
|
||||||
|
c.aliases.some((a) => a.includes(query.toLowerCase())) ||
|
||||||
|
c.description.toLowerCase().includes(query.toLowerCase()),
|
||||||
|
);
|
||||||
|
if (filtered.length === 0) return false;
|
||||||
|
const idx = Math.min(autocompleteIndex, filtered.length - 1);
|
||||||
|
const selected = filtered[idx];
|
||||||
|
if (selected) {
|
||||||
|
setInput(`/${selected.name} `);
|
||||||
|
setShowAutocomplete(false);
|
||||||
|
setAutocompleteIndex(0);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}, [showAutocomplete, input, availableCommands, autocompleteIndex, setInput]);
|
||||||
|
|
||||||
|
useInput(
|
||||||
|
(_ch, key) => {
|
||||||
|
if (key.escape && showAutocomplete) {
|
||||||
|
setShowAutocomplete(false);
|
||||||
|
setAutocompleteIndex(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tab: fill autocomplete selection
|
||||||
|
if (key.tab) {
|
||||||
|
fillAutocompleteSelection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Up arrow
|
||||||
|
if (key.upArrow) {
|
||||||
|
if (showAutocomplete) {
|
||||||
|
setAutocompleteIndex((prev) => Math.max(0, prev - 1));
|
||||||
|
} else {
|
||||||
|
const prev = navigateUp(input);
|
||||||
|
if (prev !== null) {
|
||||||
|
setInput(prev);
|
||||||
|
if (prev.startsWith('/')) setShowAutocomplete(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Down arrow
|
||||||
|
if (key.downArrow) {
|
||||||
|
if (showAutocomplete) {
|
||||||
|
const query = input.startsWith('/') ? input.slice(1) : input;
|
||||||
|
const filteredLen = availableCommands.filter(
|
||||||
|
(c) =>
|
||||||
|
!query ||
|
||||||
|
c.name.includes(query.toLowerCase()) ||
|
||||||
|
c.aliases.some((a) => a.includes(query.toLowerCase())) ||
|
||||||
|
c.description.toLowerCase().includes(query.toLowerCase()),
|
||||||
|
).length;
|
||||||
|
const maxVisible = Math.min(filteredLen, 8);
|
||||||
|
setAutocompleteIndex((prev) => Math.min(prev + 1, maxVisible - 1));
|
||||||
|
} else {
|
||||||
|
const next = navigateDown();
|
||||||
|
if (next !== null) {
|
||||||
|
setInput(next);
|
||||||
|
setShowAutocomplete(next.startsWith('/'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return/Enter on autocomplete: fill selected command
|
||||||
|
if (key.return && showAutocomplete) {
|
||||||
|
fillAutocompleteSelection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ isActive: focused },
|
||||||
|
);
|
||||||
|
|
||||||
|
const placeholder =
|
||||||
|
placeholderOverride ??
|
||||||
|
(!connected
|
||||||
|
? 'disconnected — waiting for gateway…'
|
||||||
|
: isStreaming
|
||||||
|
? 'waiting for response…'
|
||||||
|
: 'message mosaic…');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
{showAutocomplete && (
|
||||||
|
<CommandAutocomplete
|
||||||
|
commands={availableCommands}
|
||||||
|
selectedIndex={autocompleteIndex}
|
||||||
|
inputValue={input}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Box paddingX={1} borderStyle="single" borderColor="gray">
|
||||||
|
<Text bold color="green">
|
||||||
|
{'❯ '}
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
value={input}
|
||||||
|
onChange={handleChange}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
placeholder={placeholder}
|
||||||
|
focus={focused}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
192
packages/mosaic/src/tui/components/message-list.tsx
Normal file
192
packages/mosaic/src/tui/components/message-list.tsx
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Box, Text } from 'ink';
|
||||||
|
import Spinner from 'ink-spinner';
|
||||||
|
import type { Message, ToolCall } from '../hooks/use-socket.js';
|
||||||
|
|
||||||
|
export interface MessageListProps {
|
||||||
|
messages: Message[];
|
||||||
|
isStreaming: boolean;
|
||||||
|
currentStreamText: string;
|
||||||
|
currentThinkingText: string;
|
||||||
|
activeToolCalls: ToolCall[];
|
||||||
|
scrollOffset?: number;
|
||||||
|
viewportSize?: number;
|
||||||
|
isScrolledUp?: boolean;
|
||||||
|
highlightedMessageIndices?: Set<number>;
|
||||||
|
currentHighlightIndex?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(date: Date): string {
|
||||||
|
return date.toLocaleTimeString('en-US', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function SystemMessageBubble({ msg }: { msg: Message }) {
|
||||||
|
return (
|
||||||
|
<Box flexDirection="row" marginBottom={1} marginLeft={2}>
|
||||||
|
<Text dimColor>{'⚙ '}</Text>
|
||||||
|
<Text dimColor wrap="wrap">
|
||||||
|
{msg.content}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MessageBubble({
|
||||||
|
msg,
|
||||||
|
highlight,
|
||||||
|
}: {
|
||||||
|
msg: Message;
|
||||||
|
highlight?: 'match' | 'current' | undefined;
|
||||||
|
}) {
|
||||||
|
if (msg.role === 'system') {
|
||||||
|
return <SystemMessageBubble msg={msg} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isUser = msg.role === 'user';
|
||||||
|
const prefix = isUser ? '❯' : '◆';
|
||||||
|
const color = isUser ? 'green' : 'cyan';
|
||||||
|
|
||||||
|
const borderIndicator =
|
||||||
|
highlight === 'current' ? (
|
||||||
|
<Text color="yellowBright" bold>
|
||||||
|
▌{' '}
|
||||||
|
</Text>
|
||||||
|
) : highlight === 'match' ? (
|
||||||
|
<Text color="yellow">▌ </Text>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="row" marginBottom={1}>
|
||||||
|
{borderIndicator}
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<Box>
|
||||||
|
<Text bold color={color}>
|
||||||
|
{prefix}{' '}
|
||||||
|
</Text>
|
||||||
|
<Text bold color={color}>
|
||||||
|
{isUser ? 'you' : 'assistant'}
|
||||||
|
</Text>
|
||||||
|
<Text dimColor> {formatTime(msg.timestamp)}</Text>
|
||||||
|
</Box>
|
||||||
|
<Box marginLeft={2}>
|
||||||
|
<Text wrap="wrap">{msg.content}</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToolCallIndicator({ toolCall }: { toolCall: ToolCall }) {
|
||||||
|
const icon = toolCall.status === 'running' ? null : toolCall.status === 'success' ? '✓' : '✗';
|
||||||
|
const color =
|
||||||
|
toolCall.status === 'running' ? 'yellow' : toolCall.status === 'success' ? 'green' : 'red';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box marginLeft={2}>
|
||||||
|
{toolCall.status === 'running' ? (
|
||||||
|
<Text color="yellow">
|
||||||
|
<Spinner type="dots" />
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text color={color}>{icon}</Text>
|
||||||
|
)}
|
||||||
|
<Text dimColor> tool: </Text>
|
||||||
|
<Text color={color}>{toolCall.toolName}</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MessageList({
|
||||||
|
messages,
|
||||||
|
isStreaming,
|
||||||
|
currentStreamText,
|
||||||
|
currentThinkingText,
|
||||||
|
activeToolCalls,
|
||||||
|
scrollOffset,
|
||||||
|
viewportSize,
|
||||||
|
isScrolledUp,
|
||||||
|
highlightedMessageIndices,
|
||||||
|
currentHighlightIndex,
|
||||||
|
}: MessageListProps) {
|
||||||
|
const useSlicing = scrollOffset != null && viewportSize != null;
|
||||||
|
const visibleMessages = useSlicing
|
||||||
|
? messages.slice(scrollOffset, scrollOffset + viewportSize)
|
||||||
|
: messages;
|
||||||
|
const hiddenAbove = useSlicing ? scrollOffset : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" flexGrow={1} paddingX={1}>
|
||||||
|
{isScrolledUp && hiddenAbove > 0 && (
|
||||||
|
<Box justifyContent="center">
|
||||||
|
<Text dimColor>↑ {hiddenAbove} more messages ↑</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{messages.length === 0 && !isStreaming && (
|
||||||
|
<Box justifyContent="center" marginY={1}>
|
||||||
|
<Text dimColor>No messages yet. Type below to start a conversation.</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{visibleMessages.map((msg, i) => {
|
||||||
|
const globalIndex = hiddenAbove + i;
|
||||||
|
const highlight =
|
||||||
|
globalIndex === currentHighlightIndex
|
||||||
|
? ('current' as const)
|
||||||
|
: highlightedMessageIndices?.has(globalIndex)
|
||||||
|
? ('match' as const)
|
||||||
|
: undefined;
|
||||||
|
return <MessageBubble key={globalIndex} msg={msg} highlight={highlight} />;
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Active thinking */}
|
||||||
|
{isStreaming && currentThinkingText && (
|
||||||
|
<Box flexDirection="column" marginBottom={1} marginLeft={2}>
|
||||||
|
<Text dimColor italic>
|
||||||
|
💭 {currentThinkingText}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Active tool calls */}
|
||||||
|
{activeToolCalls.length > 0 && (
|
||||||
|
<Box flexDirection="column" marginBottom={1}>
|
||||||
|
{activeToolCalls.map((tc) => (
|
||||||
|
<ToolCallIndicator key={tc.toolCallId} toolCall={tc} />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Streaming response */}
|
||||||
|
{isStreaming && currentStreamText && (
|
||||||
|
<Box flexDirection="column" marginBottom={1}>
|
||||||
|
<Box>
|
||||||
|
<Text bold color="cyan">
|
||||||
|
◆{' '}
|
||||||
|
</Text>
|
||||||
|
<Text bold color="cyan">
|
||||||
|
assistant
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box marginLeft={2}>
|
||||||
|
<Text wrap="wrap">{currentStreamText}</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Waiting spinner */}
|
||||||
|
{isStreaming && !currentStreamText && activeToolCalls.length === 0 && (
|
||||||
|
<Box marginLeft={2}>
|
||||||
|
<Text color="cyan">
|
||||||
|
<Spinner type="dots" />
|
||||||
|
</Text>
|
||||||
|
<Text dimColor> thinking…</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
packages/mosaic/src/tui/components/search-bar.tsx
Normal file
60
packages/mosaic/src/tui/components/search-bar.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Box, Text, useInput } from 'ink';
|
||||||
|
import TextInput from 'ink-text-input';
|
||||||
|
|
||||||
|
export interface SearchBarProps {
|
||||||
|
query: string;
|
||||||
|
onQueryChange: (q: string) => void;
|
||||||
|
totalMatches: number;
|
||||||
|
currentMatch: number;
|
||||||
|
onNext: () => void;
|
||||||
|
onPrev: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
focused: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchBar({
|
||||||
|
query,
|
||||||
|
onQueryChange,
|
||||||
|
totalMatches,
|
||||||
|
currentMatch,
|
||||||
|
onNext,
|
||||||
|
onPrev,
|
||||||
|
onClose,
|
||||||
|
focused,
|
||||||
|
}: SearchBarProps) {
|
||||||
|
useInput(
|
||||||
|
(_input, key) => {
|
||||||
|
if (key.upArrow) {
|
||||||
|
onPrev();
|
||||||
|
}
|
||||||
|
if (key.downArrow) {
|
||||||
|
onNext();
|
||||||
|
}
|
||||||
|
if (key.escape) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ isActive: focused },
|
||||||
|
);
|
||||||
|
|
||||||
|
const borderColor = focused ? 'yellow' : 'gray';
|
||||||
|
|
||||||
|
const matchDisplay =
|
||||||
|
query.length >= 2
|
||||||
|
? totalMatches > 0
|
||||||
|
? `${String(currentMatch + 1)}/${String(totalMatches)}`
|
||||||
|
: 'no matches'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box borderStyle="round" borderColor={borderColor} paddingX={1} flexDirection="row" gap={1}>
|
||||||
|
<Text>🔍</Text>
|
||||||
|
<Box flexGrow={1}>
|
||||||
|
<TextInput value={query} onChange={onQueryChange} focus={focused} />
|
||||||
|
</Box>
|
||||||
|
{matchDisplay && <Text dimColor>{matchDisplay}</Text>}
|
||||||
|
<Text dimColor>↑↓ navigate · Esc close</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
143
packages/mosaic/src/tui/components/sidebar.tsx
Normal file
143
packages/mosaic/src/tui/components/sidebar.tsx
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Box, Text, useInput } from 'ink';
|
||||||
|
import type { ConversationSummary } from '../hooks/use-conversations.js';
|
||||||
|
|
||||||
|
export interface SidebarProps {
|
||||||
|
conversations: ConversationSummary[];
|
||||||
|
activeConversationId: string | undefined;
|
||||||
|
selectedIndex: number;
|
||||||
|
onSelectIndex: (index: number) => void;
|
||||||
|
onSwitchConversation: (id: string) => void;
|
||||||
|
onDeleteConversation: (id: string) => void;
|
||||||
|
loading: boolean;
|
||||||
|
focused: boolean;
|
||||||
|
width: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRelativeTime(dateStr: string): string {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now.getTime() - date.getTime();
|
||||||
|
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
if (diffDays === 0) {
|
||||||
|
const hh = String(date.getHours()).padStart(2, '0');
|
||||||
|
const mm = String(date.getMinutes()).padStart(2, '0');
|
||||||
|
return `${hh}:${mm}`;
|
||||||
|
}
|
||||||
|
if (diffDays < 7) {
|
||||||
|
return `${diffDays}d ago`;
|
||||||
|
}
|
||||||
|
const months = [
|
||||||
|
'Jan',
|
||||||
|
'Feb',
|
||||||
|
'Mar',
|
||||||
|
'Apr',
|
||||||
|
'May',
|
||||||
|
'Jun',
|
||||||
|
'Jul',
|
||||||
|
'Aug',
|
||||||
|
'Sep',
|
||||||
|
'Oct',
|
||||||
|
'Nov',
|
||||||
|
'Dec',
|
||||||
|
];
|
||||||
|
const mon = months[date.getMonth()];
|
||||||
|
const dd = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${mon} ${dd}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncate(text: string, maxLen: number): string {
|
||||||
|
if (text.length <= maxLen) return text;
|
||||||
|
return text.slice(0, maxLen - 1) + '…';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Sidebar({
|
||||||
|
conversations,
|
||||||
|
activeConversationId,
|
||||||
|
selectedIndex,
|
||||||
|
onSelectIndex,
|
||||||
|
onSwitchConversation,
|
||||||
|
onDeleteConversation,
|
||||||
|
loading,
|
||||||
|
focused,
|
||||||
|
width,
|
||||||
|
}: SidebarProps) {
|
||||||
|
useInput(
|
||||||
|
(_input, key) => {
|
||||||
|
if (key.upArrow) {
|
||||||
|
onSelectIndex(Math.max(0, selectedIndex - 1));
|
||||||
|
}
|
||||||
|
if (key.downArrow) {
|
||||||
|
onSelectIndex(Math.min(conversations.length - 1, selectedIndex + 1));
|
||||||
|
}
|
||||||
|
if (key.return) {
|
||||||
|
const conv = conversations[selectedIndex];
|
||||||
|
if (conv) {
|
||||||
|
onSwitchConversation(conv.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (_input === 'd') {
|
||||||
|
const conv = conversations[selectedIndex];
|
||||||
|
if (conv) {
|
||||||
|
onDeleteConversation(conv.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ isActive: focused },
|
||||||
|
);
|
||||||
|
|
||||||
|
const borderColor = focused ? 'cyan' : 'gray';
|
||||||
|
// Available width for content inside border + padding
|
||||||
|
const innerWidth = width - 4; // 2 border + 2 padding
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
flexDirection="column"
|
||||||
|
width={width}
|
||||||
|
borderStyle="single"
|
||||||
|
borderColor={borderColor}
|
||||||
|
paddingX={1}
|
||||||
|
>
|
||||||
|
<Text bold color="cyan">
|
||||||
|
Conversations
|
||||||
|
</Text>
|
||||||
|
<Box marginTop={0} flexDirection="column" flexGrow={1}>
|
||||||
|
{loading && conversations.length === 0 ? (
|
||||||
|
<Text dimColor>Loading…</Text>
|
||||||
|
) : conversations.length === 0 ? (
|
||||||
|
<Text dimColor>No conversations</Text>
|
||||||
|
) : (
|
||||||
|
conversations.map((conv, idx) => {
|
||||||
|
const isActive = conv.id === activeConversationId;
|
||||||
|
const isSelected = idx === selectedIndex && focused;
|
||||||
|
const marker = isActive ? '● ' : ' ';
|
||||||
|
const time = formatRelativeTime(conv.updatedAt);
|
||||||
|
const title = conv.title ?? 'Untitled';
|
||||||
|
// marker(2) + title + space(1) + time
|
||||||
|
const maxTitleLen = Math.max(4, innerWidth - marker.length - time.length - 1);
|
||||||
|
const displayTitle = truncate(title, maxTitleLen);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box key={conv.id}>
|
||||||
|
<Text
|
||||||
|
inverse={isSelected}
|
||||||
|
color={isActive ? 'cyan' : undefined}
|
||||||
|
dimColor={!isActive && !isSelected}
|
||||||
|
>
|
||||||
|
{marker}
|
||||||
|
{displayTitle}
|
||||||
|
{' '.repeat(
|
||||||
|
Math.max(0, innerWidth - marker.length - displayTitle.length - time.length),
|
||||||
|
)}
|
||||||
|
{time}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
{focused && <Text dimColor>↑↓ navigate • enter switch • d delete</Text>}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
99
packages/mosaic/src/tui/components/top-bar.tsx
Normal file
99
packages/mosaic/src/tui/components/top-bar.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Box, Text } from 'ink';
|
||||||
|
|
||||||
|
export interface TopBarProps {
|
||||||
|
gatewayUrl: string;
|
||||||
|
version: string;
|
||||||
|
modelName: string | null;
|
||||||
|
thinkingLevel: string;
|
||||||
|
contextWindow: number;
|
||||||
|
agentName: string;
|
||||||
|
connected: boolean;
|
||||||
|
connecting: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compact the URL — strip protocol */
|
||||||
|
function compactHost(url: string): string {
|
||||||
|
return url.replace(/^https?:\/\//, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatContextWindow(n: number): string {
|
||||||
|
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(0)}M`;
|
||||||
|
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}k`;
|
||||||
|
return String(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mosaic 3×3 icon — brand tiles with black gaps (windmill cross pattern)
|
||||||
|
*
|
||||||
|
* Layout:
|
||||||
|
* blue ·· purple
|
||||||
|
* ·· pink ··
|
||||||
|
* amber ·· teal
|
||||||
|
*/
|
||||||
|
// Two-space gap between tiles (extracted to avoid prettier collapse)
|
||||||
|
const GAP = ' ';
|
||||||
|
|
||||||
|
function MosaicIcon() {
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" marginRight={2}>
|
||||||
|
<Text>
|
||||||
|
<Text color="#2f80ff">██</Text>
|
||||||
|
<Text>{GAP}</Text>
|
||||||
|
<Text color="#8b5cf6">██</Text>
|
||||||
|
</Text>
|
||||||
|
<Text>
|
||||||
|
<Text>{GAP}</Text>
|
||||||
|
<Text color="#ec4899">██</Text>
|
||||||
|
</Text>
|
||||||
|
<Text>
|
||||||
|
<Text color="#f59e0b">██</Text>
|
||||||
|
<Text>{GAP}</Text>
|
||||||
|
<Text color="#14b8a6">██</Text>
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TopBar({
|
||||||
|
gatewayUrl,
|
||||||
|
version,
|
||||||
|
modelName,
|
||||||
|
thinkingLevel,
|
||||||
|
contextWindow,
|
||||||
|
agentName,
|
||||||
|
connected,
|
||||||
|
connecting,
|
||||||
|
}: TopBarProps) {
|
||||||
|
const host = compactHost(gatewayUrl);
|
||||||
|
const connectionIndicator = connected ? '●' : '○';
|
||||||
|
const connectionColor = connected ? 'green' : connecting ? 'yellow' : 'red';
|
||||||
|
|
||||||
|
// Build model description line like: "claude-opus-4-6 (1M context) · default"
|
||||||
|
const modelDisplay = modelName ?? 'awaiting model';
|
||||||
|
const contextStr = contextWindow > 0 ? ` (${formatContextWindow(contextWindow)} context)` : '';
|
||||||
|
const thinkingStr = thinkingLevel !== 'off' ? ` · ${thinkingLevel}` : '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box paddingX={1} paddingY={0} marginBottom={1}>
|
||||||
|
<MosaicIcon />
|
||||||
|
<Box flexDirection="column" flexGrow={1}>
|
||||||
|
<Text>
|
||||||
|
<Text bold color="#56a0ff">
|
||||||
|
Mosaic Stack
|
||||||
|
</Text>
|
||||||
|
<Text dimColor> v{version}</Text>
|
||||||
|
</Text>
|
||||||
|
<Text dimColor>
|
||||||
|
{modelDisplay}
|
||||||
|
{contextStr}
|
||||||
|
{thinkingStr} · {agentName}
|
||||||
|
</Text>
|
||||||
|
<Text>
|
||||||
|
<Text color={connectionColor}>{connectionIndicator}</Text>
|
||||||
|
<Text dimColor> {host}</Text>
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
202
packages/mosaic/src/tui/file-ref.ts
Normal file
202
packages/mosaic/src/tui/file-ref.ts
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
/**
|
||||||
|
* File reference expansion for TUI chat input.
|
||||||
|
*
|
||||||
|
* Detects @path/to/file patterns in user messages, reads the file contents,
|
||||||
|
* and inlines them as fenced code blocks in the message.
|
||||||
|
*
|
||||||
|
* Supports:
|
||||||
|
* - @relative/path.ts
|
||||||
|
* - @./relative/path.ts
|
||||||
|
* - @/absolute/path.ts
|
||||||
|
* - @~/home-relative/path.ts
|
||||||
|
*
|
||||||
|
* Also provides an /attach <path> command handler.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFile, stat } from 'node:fs/promises';
|
||||||
|
import { resolve, extname, basename } from 'node:path';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
|
||||||
|
const MAX_FILE_SIZE = 256 * 1024; // 256 KB
|
||||||
|
const MAX_FILES_PER_MESSAGE = 10;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regex to detect @file references in user input.
|
||||||
|
* Matches @<path> where path starts with /, ./, ~/, or a word char,
|
||||||
|
* and continues until whitespace or end of string.
|
||||||
|
* Excludes @mentions that look like usernames (no dots/slashes).
|
||||||
|
*/
|
||||||
|
const FILE_REF_PATTERN = /(?:^|\s)@((?:\.{0,2}\/|~\/|[a-zA-Z0-9_])[^\s]+)/g;
|
||||||
|
|
||||||
|
interface FileRefResult {
|
||||||
|
/** The expanded message text with file contents inlined */
|
||||||
|
expandedMessage: string;
|
||||||
|
/** Files that were successfully read */
|
||||||
|
filesAttached: string[];
|
||||||
|
/** Errors encountered while reading files */
|
||||||
|
errors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveFilePath(ref: string): string {
|
||||||
|
if (ref.startsWith('~/')) {
|
||||||
|
return resolve(homedir(), ref.slice(2));
|
||||||
|
}
|
||||||
|
return resolve(process.cwd(), ref);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLanguageHint(filePath: string): string {
|
||||||
|
const ext = extname(filePath).toLowerCase();
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
'.ts': 'typescript',
|
||||||
|
'.tsx': 'typescript',
|
||||||
|
'.js': 'javascript',
|
||||||
|
'.jsx': 'javascript',
|
||||||
|
'.py': 'python',
|
||||||
|
'.rb': 'ruby',
|
||||||
|
'.rs': 'rust',
|
||||||
|
'.go': 'go',
|
||||||
|
'.java': 'java',
|
||||||
|
'.c': 'c',
|
||||||
|
'.cpp': 'cpp',
|
||||||
|
'.h': 'c',
|
||||||
|
'.hpp': 'cpp',
|
||||||
|
'.cs': 'csharp',
|
||||||
|
'.sh': 'bash',
|
||||||
|
'.bash': 'bash',
|
||||||
|
'.zsh': 'zsh',
|
||||||
|
'.fish': 'fish',
|
||||||
|
'.json': 'json',
|
||||||
|
'.yaml': 'yaml',
|
||||||
|
'.yml': 'yaml',
|
||||||
|
'.toml': 'toml',
|
||||||
|
'.xml': 'xml',
|
||||||
|
'.html': 'html',
|
||||||
|
'.css': 'css',
|
||||||
|
'.scss': 'scss',
|
||||||
|
'.md': 'markdown',
|
||||||
|
'.sql': 'sql',
|
||||||
|
'.graphql': 'graphql',
|
||||||
|
'.dockerfile': 'dockerfile',
|
||||||
|
'.tf': 'terraform',
|
||||||
|
'.vue': 'vue',
|
||||||
|
'.svelte': 'svelte',
|
||||||
|
};
|
||||||
|
return map[ext] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the input contains any @file references.
|
||||||
|
*/
|
||||||
|
export function hasFileRefs(input: string): boolean {
|
||||||
|
FILE_REF_PATTERN.lastIndex = 0;
|
||||||
|
return FILE_REF_PATTERN.test(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expand @file references in a message by reading file contents
|
||||||
|
* and appending them as fenced code blocks.
|
||||||
|
*/
|
||||||
|
export async function expandFileRefs(input: string): Promise<FileRefResult> {
|
||||||
|
const refs: string[] = [];
|
||||||
|
FILE_REF_PATTERN.lastIndex = 0;
|
||||||
|
let match;
|
||||||
|
while ((match = FILE_REF_PATTERN.exec(input)) !== null) {
|
||||||
|
const ref = match[1]!;
|
||||||
|
if (!refs.includes(ref)) {
|
||||||
|
refs.push(ref);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (refs.length === 0) {
|
||||||
|
return { expandedMessage: input, filesAttached: [], errors: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (refs.length > MAX_FILES_PER_MESSAGE) {
|
||||||
|
return {
|
||||||
|
expandedMessage: input,
|
||||||
|
filesAttached: [],
|
||||||
|
errors: [`Too many file references (${refs.length}). Maximum is ${MAX_FILES_PER_MESSAGE}.`],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const filesAttached: string[] = [];
|
||||||
|
const errors: string[] = [];
|
||||||
|
const attachments: string[] = [];
|
||||||
|
|
||||||
|
for (const ref of refs) {
|
||||||
|
const filePath = resolveFilePath(ref);
|
||||||
|
try {
|
||||||
|
const info = await stat(filePath);
|
||||||
|
if (!info.isFile()) {
|
||||||
|
errors.push(`@${ref}: not a file`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (info.size > MAX_FILE_SIZE) {
|
||||||
|
errors.push(
|
||||||
|
`@${ref}: file too large (${(info.size / 1024).toFixed(0)} KB, limit ${MAX_FILE_SIZE / 1024} KB)`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const content = await readFile(filePath, 'utf8');
|
||||||
|
const lang = getLanguageHint(filePath);
|
||||||
|
const name = basename(filePath);
|
||||||
|
attachments.push(`\n📎 ${ref} (${name}):\n\`\`\`${lang}\n${content}\n\`\`\``);
|
||||||
|
filesAttached.push(ref);
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
// Only report meaningful errors — ENOENT is common for false @mention matches
|
||||||
|
if (msg.includes('ENOENT')) {
|
||||||
|
// Check if this looks like a file path (has extension or slash)
|
||||||
|
if (ref.includes('/') || ref.includes('.')) {
|
||||||
|
errors.push(`@${ref}: file not found`);
|
||||||
|
}
|
||||||
|
// Otherwise silently skip — likely an @mention, not a file ref
|
||||||
|
} else {
|
||||||
|
errors.push(`@${ref}: ${msg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attachments.length === 0) {
|
||||||
|
return { expandedMessage: input, filesAttached, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
const expandedMessage = input + '\n' + attachments.join('\n');
|
||||||
|
return { expandedMessage, filesAttached, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle the /attach <path> command.
|
||||||
|
* Reads a file and returns the content formatted for inclusion in the chat.
|
||||||
|
*/
|
||||||
|
export async function handleAttachCommand(
|
||||||
|
args: string,
|
||||||
|
): Promise<{ content: string; error?: string }> {
|
||||||
|
const filePath = args.trim();
|
||||||
|
if (!filePath) {
|
||||||
|
return { content: '', error: 'Usage: /attach <file-path>' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolved = resolveFilePath(filePath);
|
||||||
|
try {
|
||||||
|
const info = await stat(resolved);
|
||||||
|
if (!info.isFile()) {
|
||||||
|
return { content: '', error: `Not a file: ${filePath}` };
|
||||||
|
}
|
||||||
|
if (info.size > MAX_FILE_SIZE) {
|
||||||
|
return {
|
||||||
|
content: '',
|
||||||
|
error: `File too large (${(info.size / 1024).toFixed(0)} KB, limit ${MAX_FILE_SIZE / 1024} KB)`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const content = await readFile(resolved, 'utf8');
|
||||||
|
const lang = getLanguageHint(resolved);
|
||||||
|
const name = basename(resolved);
|
||||||
|
return {
|
||||||
|
content: `📎 Attached file: ${name} (${filePath})\n\`\`\`${lang}\n${content}\n\`\`\``,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
return { content: '', error: `Failed to read file: ${msg}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
438
packages/mosaic/src/tui/gateway-api.ts
Normal file
438
packages/mosaic/src/tui/gateway-api.ts
Normal file
@@ -0,0 +1,438 @@
|
|||||||
|
/**
|
||||||
|
* Minimal gateway REST API client for the TUI and CLI commands.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ModelInfo {
|
||||||
|
id: string;
|
||||||
|
provider: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProviderInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
available: boolean;
|
||||||
|
models: ModelInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionInfo {
|
||||||
|
id: string;
|
||||||
|
provider: string;
|
||||||
|
modelId: string;
|
||||||
|
createdAt: string;
|
||||||
|
promptCount: number;
|
||||||
|
channels: string[];
|
||||||
|
durationMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionListResult {
|
||||||
|
sessions: SessionInfo[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Agent Config types ──
|
||||||
|
|
||||||
|
export interface AgentConfigInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
provider: string;
|
||||||
|
model: string;
|
||||||
|
status: string;
|
||||||
|
projectId: string | null;
|
||||||
|
ownerId: string | null;
|
||||||
|
systemPrompt: string | null;
|
||||||
|
allowedTools: string[] | null;
|
||||||
|
skills: string[] | null;
|
||||||
|
isSystem: boolean;
|
||||||
|
config: Record<string, unknown> | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Project types ──
|
||||||
|
|
||||||
|
export interface ProjectInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
status: string;
|
||||||
|
ownerId: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mission types ──
|
||||||
|
|
||||||
|
export interface MissionInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
status: string;
|
||||||
|
projectId: string | null;
|
||||||
|
userId: string | null;
|
||||||
|
phase: string | null;
|
||||||
|
milestones: Record<string, unknown>[] | null;
|
||||||
|
config: Record<string, unknown> | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mission Task types ──
|
||||||
|
|
||||||
|
export interface MissionTaskInfo {
|
||||||
|
id: string;
|
||||||
|
missionId: string;
|
||||||
|
taskId: string | null;
|
||||||
|
userId: string;
|
||||||
|
status: string;
|
||||||
|
description: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
pr: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ──
|
||||||
|
|
||||||
|
function headers(sessionCookie: string, gatewayUrl: string) {
|
||||||
|
return { Cookie: sessionCookie, Origin: gatewayUrl };
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonHeaders(sessionCookie: string, gatewayUrl: string) {
|
||||||
|
return { ...headers(sessionCookie, gatewayUrl), 'Content-Type': 'application/json' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleResponse<T>(res: Response, errorPrefix: string): Promise<T> {
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.text().catch(() => '');
|
||||||
|
throw new Error(`${errorPrefix} (${res.status}): ${body}`);
|
||||||
|
}
|
||||||
|
return (await res.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Conversation types ──
|
||||||
|
|
||||||
|
export interface ConversationInfo {
|
||||||
|
id: string;
|
||||||
|
title: string | null;
|
||||||
|
archived: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Conversation endpoints ──
|
||||||
|
|
||||||
|
export async function createConversation(
|
||||||
|
gatewayUrl: string,
|
||||||
|
sessionCookie: string,
|
||||||
|
data: { title?: string; projectId?: string } = {},
|
||||||
|
): Promise<ConversationInfo> {
|
||||||
|
const res = await fetch(`${gatewayUrl}/api/conversations`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: jsonHeaders(sessionCookie, gatewayUrl),
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
return handleResponse<ConversationInfo>(res, 'Failed to create conversation');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Provider / Model endpoints ──
|
||||||
|
|
||||||
|
export async function fetchAvailableModels(
|
||||||
|
gatewayUrl: string,
|
||||||
|
sessionCookie?: string,
|
||||||
|
): Promise<ModelInfo[]> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${gatewayUrl}/api/providers/models`, {
|
||||||
|
headers: {
|
||||||
|
...(sessionCookie ? { Cookie: sessionCookie } : {}),
|
||||||
|
Origin: gatewayUrl,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!res.ok) return [];
|
||||||
|
const data = (await res.json()) as ModelInfo[];
|
||||||
|
return Array.isArray(data) ? data : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchProviders(
|
||||||
|
gatewayUrl: string,
|
||||||
|
sessionCookie?: string,
|
||||||
|
): Promise<ProviderInfo[]> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${gatewayUrl}/api/providers`, {
|
||||||
|
headers: {
|
||||||
|
...(sessionCookie ? { Cookie: sessionCookie } : {}),
|
||||||
|
Origin: gatewayUrl,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!res.ok) return [];
|
||||||
|
const data = (await res.json()) as ProviderInfo[];
|
||||||
|
return Array.isArray(data) ? data : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Session endpoints ──
|
||||||
|
|
||||||
|
export async function fetchSessions(
|
||||||
|
gatewayUrl: string,
|
||||||
|
sessionCookie: string,
|
||||||
|
): Promise<SessionListResult> {
|
||||||
|
const res = await fetch(`${gatewayUrl}/api/sessions`, {
|
||||||
|
headers: headers(sessionCookie, gatewayUrl),
|
||||||
|
});
|
||||||
|
return handleResponse<SessionListResult>(res, 'Failed to list sessions');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSession(
|
||||||
|
gatewayUrl: string,
|
||||||
|
sessionCookie: string,
|
||||||
|
sessionId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const res = await fetch(`${gatewayUrl}/api/sessions/${encodeURIComponent(sessionId)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: headers(sessionCookie, gatewayUrl),
|
||||||
|
});
|
||||||
|
if (!res.ok && res.status !== 204) {
|
||||||
|
const body = await res.text().catch(() => '');
|
||||||
|
throw new Error(`Failed to destroy session (${res.status}): ${body}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Agent Config endpoints ──
|
||||||
|
|
||||||
|
export async function fetchAgentConfigs(
|
||||||
|
gatewayUrl: string,
|
||||||
|
sessionCookie: string,
|
||||||
|
): Promise<AgentConfigInfo[]> {
|
||||||
|
const res = await fetch(`${gatewayUrl}/api/agents`, {
|
||||||
|
headers: headers(sessionCookie, gatewayUrl),
|
||||||
|
});
|
||||||
|
return handleResponse<AgentConfigInfo[]>(res, 'Failed to list agents');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAgentConfig(
|
||||||
|
gatewayUrl: string,
|
||||||
|
sessionCookie: string,
|
||||||
|
id: string,
|
||||||
|
): Promise<AgentConfigInfo> {
|
||||||
|
const res = await fetch(`${gatewayUrl}/api/agents/${encodeURIComponent(id)}`, {
|
||||||
|
headers: headers(sessionCookie, gatewayUrl),
|
||||||
|
});
|
||||||
|
return handleResponse<AgentConfigInfo>(res, 'Failed to get agent');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createAgentConfig(
|
||||||
|
gatewayUrl: string,
|
||||||
|
sessionCookie: string,
|
||||||
|
data: {
|
||||||
|
name: string;
|
||||||
|
provider: string;
|
||||||
|
model: string;
|
||||||
|
projectId?: string;
|
||||||
|
systemPrompt?: string;
|
||||||
|
allowedTools?: string[];
|
||||||
|
skills?: string[];
|
||||||
|
config?: Record<string, unknown>;
|
||||||
|
},
|
||||||
|
): Promise<AgentConfigInfo> {
|
||||||
|
const res = await fetch(`${gatewayUrl}/api/agents`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: jsonHeaders(sessionCookie, gatewayUrl),
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
return handleResponse<AgentConfigInfo>(res, 'Failed to create agent');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAgentConfig(
|
||||||
|
gatewayUrl: string,
|
||||||
|
sessionCookie: string,
|
||||||
|
id: string,
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
): Promise<AgentConfigInfo> {
|
||||||
|
const res = await fetch(`${gatewayUrl}/api/agents/${encodeURIComponent(id)}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: jsonHeaders(sessionCookie, gatewayUrl),
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
return handleResponse<AgentConfigInfo>(res, 'Failed to update agent');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAgentConfig(
|
||||||
|
gatewayUrl: string,
|
||||||
|
sessionCookie: string,
|
||||||
|
id: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const res = await fetch(`${gatewayUrl}/api/agents/${encodeURIComponent(id)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: headers(sessionCookie, gatewayUrl),
|
||||||
|
});
|
||||||
|
if (!res.ok && res.status !== 204) {
|
||||||
|
const body = await res.text().catch(() => '');
|
||||||
|
throw new Error(`Failed to delete agent (${res.status}): ${body}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Project endpoints ──
|
||||||
|
|
||||||
|
export async function fetchProjects(
|
||||||
|
gatewayUrl: string,
|
||||||
|
sessionCookie: string,
|
||||||
|
): Promise<ProjectInfo[]> {
|
||||||
|
const res = await fetch(`${gatewayUrl}/api/projects`, {
|
||||||
|
headers: headers(sessionCookie, gatewayUrl),
|
||||||
|
});
|
||||||
|
return handleResponse<ProjectInfo[]>(res, 'Failed to list projects');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mission endpoints ──
|
||||||
|
|
||||||
|
export async function fetchMissions(
|
||||||
|
gatewayUrl: string,
|
||||||
|
sessionCookie: string,
|
||||||
|
): Promise<MissionInfo[]> {
|
||||||
|
const res = await fetch(`${gatewayUrl}/api/missions`, {
|
||||||
|
headers: headers(sessionCookie, gatewayUrl),
|
||||||
|
});
|
||||||
|
return handleResponse<MissionInfo[]>(res, 'Failed to list missions');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchMission(
|
||||||
|
gatewayUrl: string,
|
||||||
|
sessionCookie: string,
|
||||||
|
id: string,
|
||||||
|
): Promise<MissionInfo> {
|
||||||
|
const res = await fetch(`${gatewayUrl}/api/missions/${encodeURIComponent(id)}`, {
|
||||||
|
headers: headers(sessionCookie, gatewayUrl),
|
||||||
|
});
|
||||||
|
return handleResponse<MissionInfo>(res, 'Failed to get mission');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createMission(
|
||||||
|
gatewayUrl: string,
|
||||||
|
sessionCookie: string,
|
||||||
|
data: {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
projectId?: string;
|
||||||
|
status?: string;
|
||||||
|
phase?: string;
|
||||||
|
milestones?: Record<string, unknown>[];
|
||||||
|
config?: Record<string, unknown>;
|
||||||
|
},
|
||||||
|
): Promise<MissionInfo> {
|
||||||
|
const res = await fetch(`${gatewayUrl}/api/missions`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: jsonHeaders(sessionCookie, gatewayUrl),
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
return handleResponse<MissionInfo>(res, 'Failed to create mission');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateMission(
|
||||||
|
gatewayUrl: string,
|
||||||
|
sessionCookie: string,
|
||||||
|
id: string,
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
): Promise<MissionInfo> {
|
||||||
|
const res = await fetch(`${gatewayUrl}/api/missions/${encodeURIComponent(id)}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: jsonHeaders(sessionCookie, gatewayUrl),
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
return handleResponse<MissionInfo>(res, 'Failed to update mission');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteMission(
|
||||||
|
gatewayUrl: string,
|
||||||
|
sessionCookie: string,
|
||||||
|
id: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const res = await fetch(`${gatewayUrl}/api/missions/${encodeURIComponent(id)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: headers(sessionCookie, gatewayUrl),
|
||||||
|
});
|
||||||
|
if (!res.ok && res.status !== 204) {
|
||||||
|
const body = await res.text().catch(() => '');
|
||||||
|
throw new Error(`Failed to delete mission (${res.status}): ${body}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Conversation Message types ──
|
||||||
|
|
||||||
|
export interface ConversationMessage {
|
||||||
|
id: string;
|
||||||
|
role: 'user' | 'assistant' | 'system' | 'tool';
|
||||||
|
content: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Conversation Message endpoints ──
|
||||||
|
|
||||||
|
export async function fetchConversationMessages(
|
||||||
|
gatewayUrl: string,
|
||||||
|
sessionCookie: string,
|
||||||
|
conversationId: string,
|
||||||
|
): Promise<ConversationMessage[]> {
|
||||||
|
const res = await fetch(
|
||||||
|
`${gatewayUrl}/api/conversations/${encodeURIComponent(conversationId)}/messages`,
|
||||||
|
{
|
||||||
|
headers: headers(sessionCookie, gatewayUrl),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return handleResponse<ConversationMessage[]>(res, 'Failed to fetch conversation messages');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mission Task endpoints ──
|
||||||
|
|
||||||
|
export async function fetchMissionTasks(
|
||||||
|
gatewayUrl: string,
|
||||||
|
sessionCookie: string,
|
||||||
|
missionId: string,
|
||||||
|
): Promise<MissionTaskInfo[]> {
|
||||||
|
const res = await fetch(`${gatewayUrl}/api/missions/${encodeURIComponent(missionId)}/tasks`, {
|
||||||
|
headers: headers(sessionCookie, gatewayUrl),
|
||||||
|
});
|
||||||
|
return handleResponse<MissionTaskInfo[]>(res, 'Failed to list mission tasks');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createMissionTask(
|
||||||
|
gatewayUrl: string,
|
||||||
|
sessionCookie: string,
|
||||||
|
missionId: string,
|
||||||
|
data: {
|
||||||
|
description?: string;
|
||||||
|
status?: string;
|
||||||
|
notes?: string;
|
||||||
|
pr?: string;
|
||||||
|
taskId?: string;
|
||||||
|
},
|
||||||
|
): Promise<MissionTaskInfo> {
|
||||||
|
const res = await fetch(`${gatewayUrl}/api/missions/${encodeURIComponent(missionId)}/tasks`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: jsonHeaders(sessionCookie, gatewayUrl),
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
return handleResponse<MissionTaskInfo>(res, 'Failed to create mission task');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateMissionTask(
|
||||||
|
gatewayUrl: string,
|
||||||
|
sessionCookie: string,
|
||||||
|
missionId: string,
|
||||||
|
taskId: string,
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
): Promise<MissionTaskInfo> {
|
||||||
|
const res = await fetch(
|
||||||
|
`${gatewayUrl}/api/missions/${encodeURIComponent(missionId)}/tasks/${encodeURIComponent(taskId)}`,
|
||||||
|
{
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: jsonHeaders(sessionCookie, gatewayUrl),
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return handleResponse<MissionTaskInfo>(res, 'Failed to update mission task');
|
||||||
|
}
|
||||||
37
packages/mosaic/src/tui/hooks/use-app-mode.ts
Normal file
37
packages/mosaic/src/tui/hooks/use-app-mode.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
|
||||||
|
export type AppMode = 'chat' | 'sidebar' | 'search';
|
||||||
|
|
||||||
|
export interface UseAppModeReturn {
|
||||||
|
mode: AppMode;
|
||||||
|
setMode: (mode: AppMode) => void;
|
||||||
|
toggleSidebar: () => void;
|
||||||
|
sidebarOpen: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAppMode(): UseAppModeReturn {
|
||||||
|
const [mode, setModeState] = useState<AppMode>('chat');
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
|
||||||
|
const setMode = useCallback((next: AppMode) => {
|
||||||
|
setModeState(next);
|
||||||
|
if (next === 'sidebar') {
|
||||||
|
setSidebarOpen(true);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleSidebar = useCallback(() => {
|
||||||
|
setSidebarOpen((prev) => {
|
||||||
|
if (prev) {
|
||||||
|
// Closing sidebar — return to chat
|
||||||
|
setModeState('chat');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Opening sidebar — set mode to sidebar
|
||||||
|
setModeState('sidebar');
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { mode, setMode, toggleSidebar, sidebarOpen };
|
||||||
|
}
|
||||||
143
packages/mosaic/src/tui/hooks/use-conversations.ts
Normal file
143
packages/mosaic/src/tui/hooks/use-conversations.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
|
||||||
|
export interface ConversationSummary {
|
||||||
|
id: string;
|
||||||
|
title: string | null;
|
||||||
|
archived: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseConversationsOptions {
|
||||||
|
gatewayUrl: string;
|
||||||
|
sessionCookie?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseConversationsReturn {
|
||||||
|
conversations: ConversationSummary[];
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
refresh: () => Promise<void>;
|
||||||
|
createConversation: (title?: string) => Promise<ConversationSummary | null>;
|
||||||
|
deleteConversation: (id: string) => Promise<boolean>;
|
||||||
|
renameConversation: (id: string, title: string) => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useConversations(opts: UseConversationsOptions): UseConversationsReturn {
|
||||||
|
const { gatewayUrl, sessionCookie } = opts;
|
||||||
|
|
||||||
|
const [conversations, setConversations] = useState<ConversationSummary[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const mountedRef = useRef(true);
|
||||||
|
|
||||||
|
const headers = useCallback(
|
||||||
|
(includeContentType = true): Record<string, string> => {
|
||||||
|
const h: Record<string, string> = { Origin: gatewayUrl };
|
||||||
|
if (includeContentType) h['Content-Type'] = 'application/json';
|
||||||
|
if (sessionCookie) h['Cookie'] = sessionCookie;
|
||||||
|
return h;
|
||||||
|
},
|
||||||
|
[gatewayUrl, sessionCookie],
|
||||||
|
);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
if (!mountedRef.current) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${gatewayUrl}/api/conversations`, { headers: headers(false) });
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const data = (await res.json()) as ConversationSummary[];
|
||||||
|
if (mountedRef.current) {
|
||||||
|
setConversations(data);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (mountedRef.current) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mountedRef.current) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [gatewayUrl, headers]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
mountedRef.current = true;
|
||||||
|
void refresh();
|
||||||
|
return () => {
|
||||||
|
mountedRef.current = false;
|
||||||
|
};
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
const createConversation = useCallback(
|
||||||
|
async (title?: string): Promise<ConversationSummary | null> => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${gatewayUrl}/api/conversations`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: headers(),
|
||||||
|
body: JSON.stringify({ title: title ?? null }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const data = (await res.json()) as ConversationSummary;
|
||||||
|
if (mountedRef.current) {
|
||||||
|
setConversations((prev) => [data, ...prev]);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[gatewayUrl, headers],
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteConversation = useCallback(
|
||||||
|
async (id: string): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${gatewayUrl}/api/conversations/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: headers(false),
|
||||||
|
});
|
||||||
|
if (!res.ok) return false;
|
||||||
|
if (mountedRef.current) {
|
||||||
|
setConversations((prev) => prev.filter((c) => c.id !== id));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[gatewayUrl, headers],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renameConversation = useCallback(
|
||||||
|
async (id: string, title: string): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${gatewayUrl}/api/conversations/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: headers(),
|
||||||
|
body: JSON.stringify({ title }),
|
||||||
|
});
|
||||||
|
if (!res.ok) return false;
|
||||||
|
if (mountedRef.current) {
|
||||||
|
setConversations((prev) => prev.map((c) => (c.id === id ? { ...c, title } : c)));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[gatewayUrl, headers],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
conversations,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refresh,
|
||||||
|
createConversation,
|
||||||
|
deleteConversation,
|
||||||
|
renameConversation,
|
||||||
|
};
|
||||||
|
}
|
||||||
29
packages/mosaic/src/tui/hooks/use-git-info.ts
Normal file
29
packages/mosaic/src/tui/hooks/use-git-info.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { execSync } from 'node:child_process';
|
||||||
|
|
||||||
|
export interface GitInfo {
|
||||||
|
branch: string | null;
|
||||||
|
cwd: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGitInfo(): GitInfo {
|
||||||
|
const [info, setInfo] = useState<GitInfo>({
|
||||||
|
branch: null,
|
||||||
|
cwd: process.cwd(),
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const branch = execSync('git rev-parse --abbrev-ref HEAD', {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
timeout: 3000,
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
}).trim();
|
||||||
|
setInfo({ branch, cwd: process.cwd() });
|
||||||
|
} catch {
|
||||||
|
setInfo({ branch: null, cwd: process.cwd() });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return info;
|
||||||
|
}
|
||||||
126
packages/mosaic/src/tui/hooks/use-input-history.test.ts
Normal file
126
packages/mosaic/src/tui/hooks/use-input-history.test.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for input history logic extracted from useInputHistory.
|
||||||
|
* We test the pure state transitions directly rather than using
|
||||||
|
* React testing utilities to avoid react-dom version conflicts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MAX_HISTORY = 50;
|
||||||
|
|
||||||
|
function createHistoryState() {
|
||||||
|
let history: string[] = [];
|
||||||
|
let historyIndex = -1;
|
||||||
|
let savedInput = '';
|
||||||
|
|
||||||
|
function addToHistory(input: string): void {
|
||||||
|
if (!input.trim()) return;
|
||||||
|
if (history[0] === input) return;
|
||||||
|
history = [input, ...history].slice(0, MAX_HISTORY);
|
||||||
|
historyIndex = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateUp(currentInput: string): string | null {
|
||||||
|
if (history.length === 0) return null;
|
||||||
|
if (historyIndex === -1) {
|
||||||
|
savedInput = currentInput;
|
||||||
|
}
|
||||||
|
const nextIndex = Math.min(historyIndex + 1, history.length - 1);
|
||||||
|
historyIndex = nextIndex;
|
||||||
|
return history[nextIndex] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateDown(): string | null {
|
||||||
|
if (historyIndex <= 0) {
|
||||||
|
historyIndex = -1;
|
||||||
|
return savedInput;
|
||||||
|
}
|
||||||
|
const nextIndex = historyIndex - 1;
|
||||||
|
historyIndex = nextIndex;
|
||||||
|
return history[nextIndex] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetNavigation(): void {
|
||||||
|
historyIndex = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHistoryLength(): number {
|
||||||
|
return history.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { addToHistory, navigateUp, navigateDown, resetNavigation, getHistoryLength };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useInputHistory (logic)', () => {
|
||||||
|
let h: ReturnType<typeof createHistoryState>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
h = createHistoryState();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds to history on submit', () => {
|
||||||
|
h.addToHistory('hello');
|
||||||
|
h.addToHistory('world');
|
||||||
|
// navigateUp should return 'world' first (most recent)
|
||||||
|
const val = h.navigateUp('');
|
||||||
|
expect(val).toBe('world');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not add empty strings to history', () => {
|
||||||
|
h.addToHistory('');
|
||||||
|
h.addToHistory(' ');
|
||||||
|
const val = h.navigateUp('');
|
||||||
|
expect(val).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('navigateDown after up returns saved input', () => {
|
||||||
|
h.addToHistory('first');
|
||||||
|
const up = h.navigateUp('current');
|
||||||
|
expect(up).toBe('first');
|
||||||
|
const down = h.navigateDown();
|
||||||
|
expect(down).toBe('current');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not add duplicate consecutive entries', () => {
|
||||||
|
h.addToHistory('same');
|
||||||
|
h.addToHistory('same');
|
||||||
|
expect(h.getHistoryLength()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps history at MAX_HISTORY entries', () => {
|
||||||
|
for (let i = 0; i < 55; i++) {
|
||||||
|
h.addToHistory(`entry-${i}`);
|
||||||
|
}
|
||||||
|
expect(h.getHistoryLength()).toBe(50);
|
||||||
|
// Navigate to the oldest entry
|
||||||
|
let val: string | null = null;
|
||||||
|
for (let i = 0; i < 60; i++) {
|
||||||
|
val = h.navigateUp('');
|
||||||
|
}
|
||||||
|
// Oldest entry at index 49 = entry-5 (entries 54 down to 5, 50 total)
|
||||||
|
expect(val).toBe('entry-5');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('navigateUp returns null when history is empty', () => {
|
||||||
|
const val = h.navigateUp('something');
|
||||||
|
expect(val).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('navigateUp cycles through multiple entries', () => {
|
||||||
|
h.addToHistory('a');
|
||||||
|
h.addToHistory('b');
|
||||||
|
h.addToHistory('c');
|
||||||
|
expect(h.navigateUp('')).toBe('c');
|
||||||
|
expect(h.navigateUp('c')).toBe('b');
|
||||||
|
expect(h.navigateUp('b')).toBe('a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resetNavigation resets index to -1', () => {
|
||||||
|
h.addToHistory('test');
|
||||||
|
h.navigateUp('');
|
||||||
|
h.resetNavigation();
|
||||||
|
// After reset, navigateUp from index -1 returns most recent again
|
||||||
|
const val = h.navigateUp('');
|
||||||
|
expect(val).toBe('test');
|
||||||
|
});
|
||||||
|
});
|
||||||
48
packages/mosaic/src/tui/hooks/use-input-history.ts
Normal file
48
packages/mosaic/src/tui/hooks/use-input-history.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
|
||||||
|
const MAX_HISTORY = 50;
|
||||||
|
|
||||||
|
export function useInputHistory() {
|
||||||
|
const [history, setHistory] = useState<string[]>([]);
|
||||||
|
const [historyIndex, setHistoryIndex] = useState<number>(-1);
|
||||||
|
const [savedInput, setSavedInput] = useState<string>('');
|
||||||
|
|
||||||
|
const addToHistory = useCallback((input: string) => {
|
||||||
|
if (!input.trim()) return;
|
||||||
|
setHistory((prev) => {
|
||||||
|
// Avoid duplicate consecutive entries
|
||||||
|
if (prev[0] === input) return prev;
|
||||||
|
return [input, ...prev].slice(0, MAX_HISTORY);
|
||||||
|
});
|
||||||
|
setHistoryIndex(-1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const navigateUp = useCallback(
|
||||||
|
(currentInput: string): string | null => {
|
||||||
|
if (history.length === 0) return null;
|
||||||
|
if (historyIndex === -1) {
|
||||||
|
setSavedInput(currentInput);
|
||||||
|
}
|
||||||
|
const nextIndex = Math.min(historyIndex + 1, history.length - 1);
|
||||||
|
setHistoryIndex(nextIndex);
|
||||||
|
return history[nextIndex] ?? null;
|
||||||
|
},
|
||||||
|
[history, historyIndex],
|
||||||
|
);
|
||||||
|
|
||||||
|
const navigateDown = useCallback((): string | null => {
|
||||||
|
if (historyIndex <= 0) {
|
||||||
|
setHistoryIndex(-1);
|
||||||
|
return savedInput;
|
||||||
|
}
|
||||||
|
const nextIndex = historyIndex - 1;
|
||||||
|
setHistoryIndex(nextIndex);
|
||||||
|
return history[nextIndex] ?? null;
|
||||||
|
}, [history, historyIndex, savedInput]);
|
||||||
|
|
||||||
|
const resetNavigation = useCallback(() => {
|
||||||
|
setHistoryIndex(-1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { addToHistory, navigateUp, navigateDown, resetNavigation };
|
||||||
|
}
|
||||||
76
packages/mosaic/src/tui/hooks/use-search.ts
Normal file
76
packages/mosaic/src/tui/hooks/use-search.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { useState, useMemo, useCallback } from 'react';
|
||||||
|
import type { Message } from './use-socket.js';
|
||||||
|
|
||||||
|
export interface SearchMatch {
|
||||||
|
messageIndex: number;
|
||||||
|
charOffset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseSearchReturn {
|
||||||
|
query: string;
|
||||||
|
setQuery: (q: string) => void;
|
||||||
|
matches: SearchMatch[];
|
||||||
|
currentMatchIndex: number;
|
||||||
|
nextMatch: () => void;
|
||||||
|
prevMatch: () => void;
|
||||||
|
clear: () => void;
|
||||||
|
totalMatches: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSearch(messages: Message[]): UseSearchReturn {
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [currentMatchIndex, setCurrentMatchIndex] = useState(0);
|
||||||
|
|
||||||
|
const matches = useMemo<SearchMatch[]>(() => {
|
||||||
|
if (query.length < 2) return [];
|
||||||
|
|
||||||
|
const lowerQuery = query.toLowerCase();
|
||||||
|
const result: SearchMatch[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < messages.length; i++) {
|
||||||
|
const msg = messages[i];
|
||||||
|
if (!msg) continue;
|
||||||
|
const content = msg.content.toLowerCase();
|
||||||
|
let offset = 0;
|
||||||
|
while (true) {
|
||||||
|
const idx = content.indexOf(lowerQuery, offset);
|
||||||
|
if (idx === -1) break;
|
||||||
|
result.push({ messageIndex: i, charOffset: idx });
|
||||||
|
offset = idx + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [query, messages]);
|
||||||
|
|
||||||
|
// Reset match index when matches change
|
||||||
|
useMemo(() => {
|
||||||
|
setCurrentMatchIndex(0);
|
||||||
|
}, [matches]);
|
||||||
|
|
||||||
|
const nextMatch = useCallback(() => {
|
||||||
|
if (matches.length === 0) return;
|
||||||
|
setCurrentMatchIndex((prev) => (prev + 1) % matches.length);
|
||||||
|
}, [matches.length]);
|
||||||
|
|
||||||
|
const prevMatch = useCallback(() => {
|
||||||
|
if (matches.length === 0) return;
|
||||||
|
setCurrentMatchIndex((prev) => (prev - 1 + matches.length) % matches.length);
|
||||||
|
}, [matches.length]);
|
||||||
|
|
||||||
|
const clear = useCallback(() => {
|
||||||
|
setQuery('');
|
||||||
|
setCurrentMatchIndex(0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
query,
|
||||||
|
setQuery,
|
||||||
|
matches,
|
||||||
|
currentMatchIndex,
|
||||||
|
nextMatch,
|
||||||
|
prevMatch,
|
||||||
|
clear,
|
||||||
|
totalMatches: matches.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
339
packages/mosaic/src/tui/hooks/use-socket.ts
Normal file
339
packages/mosaic/src/tui/hooks/use-socket.ts
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
import { type MutableRefObject, useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import { io, type Socket } from 'socket.io-client';
|
||||||
|
import type {
|
||||||
|
ServerToClientEvents,
|
||||||
|
ClientToServerEvents,
|
||||||
|
MessageAckPayload,
|
||||||
|
AgentEndPayload,
|
||||||
|
AgentTextPayload,
|
||||||
|
AgentThinkingPayload,
|
||||||
|
ToolStartPayload,
|
||||||
|
ToolEndPayload,
|
||||||
|
SessionInfoPayload,
|
||||||
|
ErrorPayload,
|
||||||
|
CommandManifestPayload,
|
||||||
|
SlashCommandResultPayload,
|
||||||
|
SystemReloadPayload,
|
||||||
|
RoutingDecisionInfo,
|
||||||
|
} from '@mosaic/types';
|
||||||
|
import { commandRegistry } from '../commands/index.js';
|
||||||
|
|
||||||
|
export interface ToolCall {
|
||||||
|
toolCallId: string;
|
||||||
|
toolName: string;
|
||||||
|
status: 'running' | 'success' | 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Message {
|
||||||
|
role: 'user' | 'assistant' | 'thinking' | 'tool' | 'system';
|
||||||
|
content: string;
|
||||||
|
timestamp: Date;
|
||||||
|
toolCalls?: ToolCall[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenUsage {
|
||||||
|
input: number;
|
||||||
|
output: number;
|
||||||
|
total: number;
|
||||||
|
cacheRead: number;
|
||||||
|
cacheWrite: number;
|
||||||
|
cost: number;
|
||||||
|
contextPercent: number;
|
||||||
|
contextWindow: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseSocketOptions {
|
||||||
|
gatewayUrl: string;
|
||||||
|
sessionCookie?: string;
|
||||||
|
initialConversationId?: string;
|
||||||
|
initialModel?: string;
|
||||||
|
initialProvider?: string;
|
||||||
|
agentId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TypedSocket = Socket<ServerToClientEvents, ClientToServerEvents>;
|
||||||
|
|
||||||
|
export interface UseSocketReturn {
|
||||||
|
connected: boolean;
|
||||||
|
connecting: boolean;
|
||||||
|
messages: Message[];
|
||||||
|
conversationId: string | undefined;
|
||||||
|
isStreaming: boolean;
|
||||||
|
currentStreamText: string;
|
||||||
|
currentThinkingText: string;
|
||||||
|
activeToolCalls: ToolCall[];
|
||||||
|
tokenUsage: TokenUsage;
|
||||||
|
modelName: string | null;
|
||||||
|
providerName: string | null;
|
||||||
|
thinkingLevel: string;
|
||||||
|
availableThinkingLevels: string[];
|
||||||
|
/** Last routing decision received from the gateway (M4-008) */
|
||||||
|
routingDecision: RoutingDecisionInfo | null;
|
||||||
|
sendMessage: (content: string) => void;
|
||||||
|
addSystemMessage: (content: string) => void;
|
||||||
|
setThinkingLevel: (level: string) => void;
|
||||||
|
switchConversation: (id: string) => void;
|
||||||
|
clearMessages: () => void;
|
||||||
|
connectionError: string | null;
|
||||||
|
socketRef: MutableRefObject<TypedSocket | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_USAGE: TokenUsage = {
|
||||||
|
input: 0,
|
||||||
|
output: 0,
|
||||||
|
total: 0,
|
||||||
|
cacheRead: 0,
|
||||||
|
cacheWrite: 0,
|
||||||
|
cost: 0,
|
||||||
|
contextPercent: 0,
|
||||||
|
contextWindow: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useSocket(opts: UseSocketOptions): UseSocketReturn {
|
||||||
|
const {
|
||||||
|
gatewayUrl,
|
||||||
|
sessionCookie,
|
||||||
|
initialConversationId,
|
||||||
|
initialModel,
|
||||||
|
initialProvider,
|
||||||
|
agentId,
|
||||||
|
} = opts;
|
||||||
|
|
||||||
|
const [connected, setConnected] = useState(false);
|
||||||
|
const [connecting, setConnecting] = useState(true);
|
||||||
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
|
const [conversationId, setConversationId] = useState(initialConversationId);
|
||||||
|
const [isStreaming, setIsStreaming] = useState(false);
|
||||||
|
const [currentStreamText, setCurrentStreamText] = useState('');
|
||||||
|
const [currentThinkingText, setCurrentThinkingText] = useState('');
|
||||||
|
const [activeToolCalls, setActiveToolCalls] = useState<ToolCall[]>([]);
|
||||||
|
const [tokenUsage, setTokenUsage] = useState<TokenUsage>(EMPTY_USAGE);
|
||||||
|
const [modelName, setModelName] = useState<string | null>(null);
|
||||||
|
const [providerName, setProviderName] = useState<string | null>(null);
|
||||||
|
const [thinkingLevel, setThinkingLevelState] = useState<string>('off');
|
||||||
|
const [availableThinkingLevels, setAvailableThinkingLevels] = useState<string[]>([]);
|
||||||
|
const [routingDecision, setRoutingDecision] = useState<RoutingDecisionInfo | null>(null);
|
||||||
|
const [connectionError, setConnectionError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const socketRef = useRef<TypedSocket | null>(null);
|
||||||
|
const conversationIdRef = useRef(conversationId);
|
||||||
|
conversationIdRef.current = conversationId;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const socket = io(`${gatewayUrl}/chat`, {
|
||||||
|
transports: ['websocket'],
|
||||||
|
extraHeaders: sessionCookie ? { Cookie: sessionCookie } : undefined,
|
||||||
|
reconnection: true,
|
||||||
|
reconnectionDelay: 2000,
|
||||||
|
reconnectionAttempts: Infinity,
|
||||||
|
}) as TypedSocket;
|
||||||
|
|
||||||
|
socketRef.current = socket;
|
||||||
|
|
||||||
|
socket.on('connect', () => {
|
||||||
|
setConnected(true);
|
||||||
|
setConnecting(false);
|
||||||
|
setConnectionError(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('disconnect', () => {
|
||||||
|
setConnected(false);
|
||||||
|
setIsStreaming(false);
|
||||||
|
setCurrentStreamText('');
|
||||||
|
setCurrentThinkingText('');
|
||||||
|
setActiveToolCalls([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.io.on('error', (err: Error) => {
|
||||||
|
setConnecting(false);
|
||||||
|
setConnectionError(err.message);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('message:ack', (data: MessageAckPayload) => {
|
||||||
|
setConversationId(data.conversationId);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('session:info', (data: SessionInfoPayload) => {
|
||||||
|
setProviderName(data.provider);
|
||||||
|
setModelName(data.modelId);
|
||||||
|
setThinkingLevelState(data.thinkingLevel);
|
||||||
|
setAvailableThinkingLevels(data.availableThinkingLevels);
|
||||||
|
// Update routing decision if provided (M4-008)
|
||||||
|
if (data.routingDecision) {
|
||||||
|
setRoutingDecision(data.routingDecision);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('agent:start', () => {
|
||||||
|
setIsStreaming(true);
|
||||||
|
setCurrentStreamText('');
|
||||||
|
setCurrentThinkingText('');
|
||||||
|
setActiveToolCalls([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('agent:text', (data: AgentTextPayload) => {
|
||||||
|
setCurrentStreamText((prev) => prev + data.text);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('agent:thinking', (data: AgentThinkingPayload) => {
|
||||||
|
setCurrentThinkingText((prev) => prev + data.text);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('agent:tool:start', (data: ToolStartPayload) => {
|
||||||
|
setActiveToolCalls((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ toolCallId: data.toolCallId, toolName: data.toolName, status: 'running' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('agent:tool:end', (data: ToolEndPayload) => {
|
||||||
|
setActiveToolCalls((prev) =>
|
||||||
|
prev.map((tc) =>
|
||||||
|
tc.toolCallId === data.toolCallId
|
||||||
|
? { ...tc, status: data.isError ? 'error' : 'success' }
|
||||||
|
: tc,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('agent:end', (data: AgentEndPayload) => {
|
||||||
|
setCurrentStreamText((prev) => {
|
||||||
|
if (prev) {
|
||||||
|
setMessages((msgs) => [
|
||||||
|
...msgs,
|
||||||
|
{ role: 'assistant', content: prev, timestamp: new Date() },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
setCurrentThinkingText('');
|
||||||
|
setActiveToolCalls([]);
|
||||||
|
setIsStreaming(false);
|
||||||
|
|
||||||
|
// Update usage from the payload
|
||||||
|
if (data.usage) {
|
||||||
|
setProviderName(data.usage.provider);
|
||||||
|
setModelName(data.usage.modelId);
|
||||||
|
setThinkingLevelState(data.usage.thinkingLevel);
|
||||||
|
setTokenUsage({
|
||||||
|
input: data.usage.tokens.input,
|
||||||
|
output: data.usage.tokens.output,
|
||||||
|
total: data.usage.tokens.total,
|
||||||
|
cacheRead: data.usage.tokens.cacheRead,
|
||||||
|
cacheWrite: data.usage.tokens.cacheWrite,
|
||||||
|
cost: data.usage.cost,
|
||||||
|
contextPercent: data.usage.context.percent ?? 0,
|
||||||
|
contextWindow: data.usage.context.window,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('error', (data: ErrorPayload) => {
|
||||||
|
setMessages((msgs) => [
|
||||||
|
...msgs,
|
||||||
|
{ role: 'assistant', content: `Error: ${data.error}`, timestamp: new Date() },
|
||||||
|
]);
|
||||||
|
setIsStreaming(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('commands:manifest', (data: CommandManifestPayload) => {
|
||||||
|
commandRegistry.updateManifest(data.manifest);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('command:result', (data: SlashCommandResultPayload) => {
|
||||||
|
const prefix = data.success ? '' : 'Error: ';
|
||||||
|
const text = data.message ?? (data.success ? 'Done.' : 'Command failed.');
|
||||||
|
setMessages((msgs) => [
|
||||||
|
...msgs,
|
||||||
|
{ role: 'system', content: `${prefix}${text}`, timestamp: new Date() },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('system:reload', (data: SystemReloadPayload) => {
|
||||||
|
commandRegistry.updateManifest({
|
||||||
|
commands: data.commands,
|
||||||
|
skills: data.skills,
|
||||||
|
version: Date.now(),
|
||||||
|
});
|
||||||
|
setMessages((msgs) => [
|
||||||
|
...msgs,
|
||||||
|
{ role: 'system', content: data.message, timestamp: new Date() },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
socket.disconnect();
|
||||||
|
};
|
||||||
|
}, [gatewayUrl, sessionCookie]);
|
||||||
|
|
||||||
|
const sendMessage = useCallback(
|
||||||
|
(content: string) => {
|
||||||
|
if (!content.trim() || isStreaming) return;
|
||||||
|
if (!socketRef.current?.connected) return;
|
||||||
|
|
||||||
|
setMessages((msgs) => [...msgs, { role: 'user', content, timestamp: new Date() }]);
|
||||||
|
|
||||||
|
socketRef.current.emit('message', {
|
||||||
|
conversationId,
|
||||||
|
content,
|
||||||
|
...(initialProvider ? { provider: initialProvider } : {}),
|
||||||
|
...(initialModel ? { modelId: initialModel } : {}),
|
||||||
|
...(agentId ? { agentId } : {}),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[conversationId, isStreaming],
|
||||||
|
);
|
||||||
|
|
||||||
|
const addSystemMessage = useCallback((content: string) => {
|
||||||
|
setMessages((msgs) => [...msgs, { role: 'system', content, timestamp: new Date() }]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setThinkingLevel = useCallback((level: string) => {
|
||||||
|
const cid = conversationIdRef.current;
|
||||||
|
if (!socketRef.current?.connected || !cid) return;
|
||||||
|
socketRef.current.emit('set:thinking', {
|
||||||
|
conversationId: cid,
|
||||||
|
level,
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearMessages = useCallback(() => {
|
||||||
|
setMessages([]);
|
||||||
|
setCurrentStreamText('');
|
||||||
|
setCurrentThinkingText('');
|
||||||
|
setActiveToolCalls([]);
|
||||||
|
setIsStreaming(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const switchConversation = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
clearMessages();
|
||||||
|
setConversationId(id);
|
||||||
|
},
|
||||||
|
[clearMessages],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
connected,
|
||||||
|
connecting,
|
||||||
|
messages,
|
||||||
|
conversationId,
|
||||||
|
isStreaming,
|
||||||
|
currentStreamText,
|
||||||
|
currentThinkingText,
|
||||||
|
activeToolCalls,
|
||||||
|
tokenUsage,
|
||||||
|
modelName,
|
||||||
|
providerName,
|
||||||
|
thinkingLevel,
|
||||||
|
availableThinkingLevels,
|
||||||
|
routingDecision,
|
||||||
|
sendMessage,
|
||||||
|
addSystemMessage,
|
||||||
|
setThinkingLevel,
|
||||||
|
switchConversation,
|
||||||
|
clearMessages,
|
||||||
|
connectionError,
|
||||||
|
socketRef,
|
||||||
|
};
|
||||||
|
}
|
||||||
80
packages/mosaic/src/tui/hooks/use-viewport.ts
Normal file
80
packages/mosaic/src/tui/hooks/use-viewport.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
|
import { useStdout } from 'ink';
|
||||||
|
|
||||||
|
export interface UseViewportOptions {
|
||||||
|
totalItems: number;
|
||||||
|
reservedLines?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseViewportReturn {
|
||||||
|
scrollOffset: number;
|
||||||
|
viewportSize: number;
|
||||||
|
isScrolledUp: boolean;
|
||||||
|
scrollToBottom: () => void;
|
||||||
|
scrollBy: (delta: number) => void;
|
||||||
|
scrollTo: (offset: number) => void;
|
||||||
|
canScrollUp: boolean;
|
||||||
|
canScrollDown: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useViewport({
|
||||||
|
totalItems,
|
||||||
|
reservedLines = 10,
|
||||||
|
}: UseViewportOptions): UseViewportReturn {
|
||||||
|
const { stdout } = useStdout();
|
||||||
|
const rows = stdout?.rows ?? 24;
|
||||||
|
const viewportSize = Math.max(1, rows - reservedLines);
|
||||||
|
|
||||||
|
const [scrollOffset, setScrollOffset] = useState(0);
|
||||||
|
const [autoFollow, setAutoFollow] = useState(true);
|
||||||
|
|
||||||
|
// Compute the maximum valid scroll offset
|
||||||
|
const maxOffset = Math.max(0, totalItems - viewportSize);
|
||||||
|
|
||||||
|
// Auto-follow: when new items arrive and auto-follow is on, snap to bottom
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoFollow) {
|
||||||
|
setScrollOffset(maxOffset);
|
||||||
|
}
|
||||||
|
}, [autoFollow, maxOffset]);
|
||||||
|
|
||||||
|
const scrollTo = useCallback(
|
||||||
|
(offset: number) => {
|
||||||
|
const clamped = Math.max(0, Math.min(offset, maxOffset));
|
||||||
|
setScrollOffset(clamped);
|
||||||
|
setAutoFollow(clamped >= maxOffset);
|
||||||
|
},
|
||||||
|
[maxOffset],
|
||||||
|
);
|
||||||
|
|
||||||
|
const scrollBy = useCallback(
|
||||||
|
(delta: number) => {
|
||||||
|
setScrollOffset((prev) => {
|
||||||
|
const next = Math.max(0, Math.min(prev + delta, maxOffset));
|
||||||
|
setAutoFollow(next >= maxOffset);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[maxOffset],
|
||||||
|
);
|
||||||
|
|
||||||
|
const scrollToBottom = useCallback(() => {
|
||||||
|
setScrollOffset(maxOffset);
|
||||||
|
setAutoFollow(true);
|
||||||
|
}, [maxOffset]);
|
||||||
|
|
||||||
|
const isScrolledUp = scrollOffset < maxOffset;
|
||||||
|
const canScrollUp = scrollOffset > 0;
|
||||||
|
const canScrollDown = scrollOffset < maxOffset;
|
||||||
|
|
||||||
|
return {
|
||||||
|
scrollOffset,
|
||||||
|
viewportSize,
|
||||||
|
isScrolledUp,
|
||||||
|
scrollToBottom,
|
||||||
|
scrollBy,
|
||||||
|
scrollTo,
|
||||||
|
canScrollUp,
|
||||||
|
canScrollDown,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -2,7 +2,8 @@
|
|||||||
"extends": "../../tsconfig.base.json",
|
"extends": "../../tsconfig.base.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
"rootDir": "src"
|
"rootDir": "src",
|
||||||
|
"jsx": "react-jsx"
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*"],
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": ["node_modules", "dist"]
|
||||||
|
|||||||
81
pnpm-lock.yaml
generated
81
pnpm-lock.yaml
generated
@@ -302,61 +302,6 @@ importers:
|
|||||||
specifier: ^2.0.0
|
specifier: ^2.0.0
|
||||||
version: 2.1.9(@types/node@24.12.0)(jsdom@29.0.0(@noble/hashes@2.0.1))(lightningcss@1.31.1)
|
version: 2.1.9(@types/node@24.12.0)(jsdom@29.0.0(@noble/hashes@2.0.1))(lightningcss@1.31.1)
|
||||||
|
|
||||||
packages/cli:
|
|
||||||
dependencies:
|
|
||||||
'@clack/prompts':
|
|
||||||
specifier: ^0.9.0
|
|
||||||
version: 0.9.1
|
|
||||||
'@mosaic/config':
|
|
||||||
specifier: workspace:^
|
|
||||||
version: link:../config
|
|
||||||
'@mosaic/mosaic':
|
|
||||||
specifier: workspace:^
|
|
||||||
version: link:../mosaic
|
|
||||||
'@mosaic/prdy':
|
|
||||||
specifier: workspace:^
|
|
||||||
version: link:../prdy
|
|
||||||
'@mosaic/quality-rails':
|
|
||||||
specifier: workspace:^
|
|
||||||
version: link:../quality-rails
|
|
||||||
'@mosaic/types':
|
|
||||||
specifier: workspace:^
|
|
||||||
version: link:../types
|
|
||||||
commander:
|
|
||||||
specifier: ^13.0.0
|
|
||||||
version: 13.1.0
|
|
||||||
ink:
|
|
||||||
specifier: ^5.0.0
|
|
||||||
version: 5.2.1(@types/react@18.3.28)(react@18.3.1)
|
|
||||||
ink-spinner:
|
|
||||||
specifier: ^5.0.0
|
|
||||||
version: 5.0.0(ink@5.2.1(@types/react@18.3.28)(react@18.3.1))(react@18.3.1)
|
|
||||||
ink-text-input:
|
|
||||||
specifier: ^6.0.0
|
|
||||||
version: 6.0.0(ink@5.2.1(@types/react@18.3.28)(react@18.3.1))(react@18.3.1)
|
|
||||||
react:
|
|
||||||
specifier: ^18.3.0
|
|
||||||
version: 18.3.1
|
|
||||||
socket.io-client:
|
|
||||||
specifier: ^4.8.0
|
|
||||||
version: 4.8.3
|
|
||||||
devDependencies:
|
|
||||||
'@types/node':
|
|
||||||
specifier: ^22.0.0
|
|
||||||
version: 22.19.15
|
|
||||||
'@types/react':
|
|
||||||
specifier: ^18.3.0
|
|
||||||
version: 18.3.28
|
|
||||||
tsx:
|
|
||||||
specifier: ^4.0.0
|
|
||||||
version: 4.21.0
|
|
||||||
typescript:
|
|
||||||
specifier: ^5.8.0
|
|
||||||
version: 5.9.3
|
|
||||||
vitest:
|
|
||||||
specifier: ^2.0.0
|
|
||||||
version: 2.1.9(@types/node@22.19.15)(jsdom@29.0.0(@noble/hashes@2.0.1))(lightningcss@1.31.1)
|
|
||||||
|
|
||||||
packages/config:
|
packages/config:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@mosaic/memory':
|
'@mosaic/memory':
|
||||||
@@ -509,6 +454,9 @@ importers:
|
|||||||
'@clack/prompts':
|
'@clack/prompts':
|
||||||
specifier: ^0.9.1
|
specifier: ^0.9.1
|
||||||
version: 0.9.1
|
version: 0.9.1
|
||||||
|
'@mosaic/config':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../config
|
||||||
'@mosaic/forge':
|
'@mosaic/forge':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../forge
|
version: link:../forge
|
||||||
@@ -527,9 +475,24 @@ importers:
|
|||||||
commander:
|
commander:
|
||||||
specifier: ^13.0.0
|
specifier: ^13.0.0
|
||||||
version: 13.1.0
|
version: 13.1.0
|
||||||
|
ink:
|
||||||
|
specifier: ^5.0.0
|
||||||
|
version: 5.2.1(@types/react@18.3.28)(react@18.3.1)
|
||||||
|
ink-spinner:
|
||||||
|
specifier: ^5.0.0
|
||||||
|
version: 5.0.0(ink@5.2.1(@types/react@18.3.28)(react@18.3.1))(react@18.3.1)
|
||||||
|
ink-text-input:
|
||||||
|
specifier: ^6.0.0
|
||||||
|
version: 6.0.0(ink@5.2.1(@types/react@18.3.28)(react@18.3.1))(react@18.3.1)
|
||||||
picocolors:
|
picocolors:
|
||||||
specifier: ^1.1.1
|
specifier: ^1.1.1
|
||||||
version: 1.1.1
|
version: 1.1.1
|
||||||
|
react:
|
||||||
|
specifier: ^18.3.0
|
||||||
|
version: 18.3.1
|
||||||
|
socket.io-client:
|
||||||
|
specifier: ^4.8.0
|
||||||
|
version: 4.8.3
|
||||||
yaml:
|
yaml:
|
||||||
specifier: ^2.6.1
|
specifier: ^2.6.1
|
||||||
version: 2.8.2
|
version: 2.8.2
|
||||||
@@ -540,6 +503,12 @@ importers:
|
|||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^22.0.0
|
specifier: ^22.0.0
|
||||||
version: 22.19.15
|
version: 22.19.15
|
||||||
|
'@types/react':
|
||||||
|
specifier: ^18.3.0
|
||||||
|
version: 18.3.28
|
||||||
|
tsx:
|
||||||
|
specifier: ^4.0.0
|
||||||
|
version: 4.21.0
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ^5.8.0
|
specifier: ^5.8.0
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
@@ -11948,7 +11917,7 @@ snapshots:
|
|||||||
type-fest: 4.41.0
|
type-fest: 4.41.0
|
||||||
widest-line: 5.0.0
|
widest-line: 5.0.0
|
||||||
wrap-ansi: 9.0.2
|
wrap-ansi: 9.0.2
|
||||||
ws: 8.19.0
|
ws: 8.20.0
|
||||||
yoga-layout: 3.2.1
|
yoga-layout: 3.2.1
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 18.3.28
|
'@types/react': 18.3.28
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ packages:
|
|||||||
- 'apps/*'
|
- 'apps/*'
|
||||||
- 'packages/*'
|
- 'packages/*'
|
||||||
- 'plugins/*'
|
- 'plugins/*'
|
||||||
|
- '!packages/cli' # merged into @mosaic/mosaic
|
||||||
|
|
||||||
ignoredBuiltDependencies:
|
ignoredBuiltDependencies:
|
||||||
- '@nestjs/core'
|
- '@nestjs/core'
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
#
|
#
|
||||||
# Installs both components:
|
# Installs both components:
|
||||||
# 1. Mosaic framework → ~/.config/mosaic/ (bash launcher, guides, runtime configs, tools)
|
# 1. Mosaic framework → ~/.config/mosaic/ (bash launcher, guides, runtime configs, tools)
|
||||||
# 2. @mosaic/cli (npm) → ~/.npm-global/ (TUI, gateway client, wizard)
|
# 2. @mosaic/mosaic (npm) → ~/.npm-global/ (CLI, TUI, gateway client, wizard)
|
||||||
#
|
#
|
||||||
# Remote install (recommended):
|
# Remote install (recommended):
|
||||||
# bash <(curl -fsSL https://git.mosaicstack.dev/mosaic/mosaic-stack/raw/branch/main/tools/install.sh)
|
# bash <(curl -fsSL https://git.mosaicstack.dev/mosaic/mosaic-stack/raw/branch/main/tools/install.sh)
|
||||||
@@ -53,7 +53,7 @@ MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}"
|
|||||||
REGISTRY="${MOSAIC_REGISTRY:-https://git.mosaicstack.dev/api/packages/mosaic/npm/}"
|
REGISTRY="${MOSAIC_REGISTRY:-https://git.mosaicstack.dev/api/packages/mosaic/npm/}"
|
||||||
SCOPE="${MOSAIC_SCOPE:-@mosaic}"
|
SCOPE="${MOSAIC_SCOPE:-@mosaic}"
|
||||||
PREFIX="${MOSAIC_PREFIX:-$HOME/.npm-global}"
|
PREFIX="${MOSAIC_PREFIX:-$HOME/.npm-global}"
|
||||||
CLI_PKG="${SCOPE}/cli"
|
CLI_PKG="${SCOPE}/mosaic"
|
||||||
REPO_BASE="https://git.mosaicstack.dev/mosaic/mosaic-stack"
|
REPO_BASE="https://git.mosaicstack.dev/mosaic/mosaic-stack"
|
||||||
ARCHIVE_URL="${REPO_BASE}/archive/${GIT_REF}.tar.gz"
|
ARCHIVE_URL="${REPO_BASE}/archive/${GIT_REF}.tar.gz"
|
||||||
|
|
||||||
@@ -208,7 +208,7 @@ if [[ "$FLAG_FRAMEWORK" == "true" ]]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
# PART 2: @mosaic/cli (npm — TUI, gateway client, wizard)
|
# PART 2: @mosaic/mosaic (npm — TUI, gateway client, wizard, CLI)
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
if [[ "$FLAG_CLI" == "true" ]]; then
|
if [[ "$FLAG_CLI" == "true" ]]; then
|
||||||
|
|||||||
Reference in New Issue
Block a user