helix-assistant-cli 0.2.14 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,150 @@
1
+ import type { AgentClient } from '../client/agent-client';
2
+ import { CYAN_BOLD, DIM, RED, YELLOW } from '../ui/colors';
3
+
4
+ interface SubagentProfile {
5
+ profile_id: string;
6
+ name: string;
7
+ }
8
+
9
+ interface SubagentStatus {
10
+ profile_id?: string;
11
+ name?: string;
12
+ status?: string;
13
+ progress?: number;
14
+ current_action?: string;
15
+ }
16
+
17
+ function boundedText(value: unknown, maxLength: number): string {
18
+ if (typeof value !== 'string') return '';
19
+ const clean = value.replace(/[\r\n|]/g, ' ').trim();
20
+ return clean.length > maxLength ? `${clean.slice(0, maxLength - 3)}...` : clean;
21
+ }
22
+
23
+ function statusLabel(status?: string): string {
24
+ const normalized = status?.toLowerCase() || 'idle';
25
+ if (['running', 'active', 'in_progress'].includes(normalized)) return 'RUNNING';
26
+ if (['failed', 'error'].includes(normalized)) return 'FAILED';
27
+ if (['completed', 'done'].includes(normalized)) return 'DONE';
28
+ if (['cancelled', 'canceled'].includes(normalized)) return 'STOPPED';
29
+ return 'IDLE';
30
+ }
31
+
32
+ export function renderSubagentPanel(
33
+ profiles: SubagentProfile[],
34
+ statuses: SubagentStatus[],
35
+ ): string {
36
+ const statusByProfile = new Map<string, SubagentStatus>();
37
+ for (const status of statuses) {
38
+ if (status.profile_id) statusByProfile.set(status.profile_id, status);
39
+ }
40
+
41
+ const rows = profiles.map((profile) => {
42
+ const status = statusByProfile.get(profile.profile_id);
43
+ const profileId = boundedText(profile.profile_id, 22);
44
+ const name = boundedText(profile.name, 18);
45
+ const state = statusLabel(status?.status);
46
+ const progress = Number.isFinite(status?.progress) ? ` ${status?.progress}%` : '';
47
+ const action = boundedText(status?.current_action, 26);
48
+ return [
49
+ profileId.padEnd(22),
50
+ name.padEnd(18),
51
+ `${state}${progress}`,
52
+ action,
53
+ ];
54
+ });
55
+
56
+ const headings = ['PROFILE ID', 'NAME', 'STATE', 'CURRENT ACTION'];
57
+ const widths = [22, 18, 16, 26];
58
+ const border = `+${widths.map((width) => '-'.repeat(width + 2)).join('+')}+`;
59
+ const row = (values: string[]) =>
60
+ `| ${values.map((value, index) => value.padEnd(widths[index])).join(' | ')} |`;
61
+ const lines = [
62
+ border,
63
+ row(headings),
64
+ border,
65
+ ...(rows.length > 0
66
+ ? rows.map(row)
67
+ : [row(['No profiles', 'available', '', ''])]),
68
+ border,
69
+ ];
70
+ return lines.join('\n');
71
+ }
72
+
73
+ function asArray<T>(
74
+ value: unknown,
75
+ label: string,
76
+ isItem: (item: unknown) => item is T,
77
+ ): T[] {
78
+ if (!Array.isArray(value) || !value.every(isItem)) {
79
+ throw new Error(`Agent returned an invalid ${label} list.`);
80
+ }
81
+ return value;
82
+ }
83
+
84
+ function isSubagentProfile(value: unknown): value is SubagentProfile {
85
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return false;
86
+ const profile = value as Record<string, unknown>;
87
+ return typeof profile.profile_id === 'string'
88
+ && typeof profile.name === 'string';
89
+ }
90
+
91
+ function isSubagentStatus(value: unknown): value is SubagentStatus {
92
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return false;
93
+ const status = value as Record<string, unknown>;
94
+ return (status.profile_id === undefined || typeof status.profile_id === 'string')
95
+ && (status.status === undefined || typeof status.status === 'string')
96
+ && (status.progress === undefined || typeof status.progress === 'number')
97
+ && (status.current_action === undefined || typeof status.current_action === 'string');
98
+ }
99
+
100
+ export async function handleSubagentsCommand(
101
+ client: AgentClient,
102
+ args = '',
103
+ ): Promise<void> {
104
+ const trimmed = args.trim();
105
+ const chatMatch = trimmed.match(/^chat\s+(\S+)\s+([\s\S]+)$/i);
106
+ if (chatMatch) {
107
+ const [, profileId, message] = chatMatch;
108
+ try {
109
+ const result = await client.request<unknown>(
110
+ 'CHAT_SUBAGENT',
111
+ { profile_id: profileId, message: message.trim() },
112
+ 120000,
113
+ );
114
+ console.log('\n' + CYAN_BOLD(` SUBAGENT ${profileId}`));
115
+ if (
116
+ result !== null
117
+ && typeof result === 'object'
118
+ && 'response' in result
119
+ && typeof result.response === 'string'
120
+ ) {
121
+ console.log(result.response);
122
+ } else {
123
+ console.log(JSON.stringify(result, null, 2));
124
+ }
125
+ console.log('');
126
+ } catch (error) {
127
+ console.log(RED(` No se pudo contactar al subagente: ${(error as Error).message}\n`));
128
+ }
129
+ return;
130
+ }
131
+
132
+ if (trimmed && !/^list$/i.test(trimmed)) {
133
+ console.log(YELLOW(' Uso: /subagents [list] | /subagents chat <id> <mensaje>\n'));
134
+ return;
135
+ }
136
+
137
+ try {
138
+ const [profilesValue, statusesValue] = await Promise.all([
139
+ client.request<unknown[]>('GET_SUBAGENT_PROFILES'),
140
+ client.request<unknown[]>('GET_SUBAGENT_STATUS'),
141
+ ]);
142
+ const profiles = asArray(profilesValue, 'subagent profile', isSubagentProfile);
143
+ const statuses = asArray(statusesValue, 'subagent status', isSubagentStatus);
144
+ console.log(`\n${CYAN_BOLD(' HELIX SUBAGENTS')}`);
145
+ console.log(renderSubagentPanel(profiles, statuses));
146
+ console.log(DIM(' Chat: /subagents chat <profile_id> <mensaje>\n'));
147
+ } catch (error) {
148
+ console.log(RED(` No se pudo cargar el panel de subagentes: ${(error as Error).message}\n`));
149
+ }
150
+ }
@@ -9,10 +9,19 @@ export interface McpDefaultDefinition {
9
9
 
10
10
  /** Catalog only: entries are never enabled or installed automatically. */
11
11
  export const MCP_DEFAULTS: McpDefaultDefinition[] = [
12
- { id: 'filesystem', label: 'Archivos locales', purpose: 'Carpeta elegida por el usuario', transport: 'stdio', activation: 'local-opt-in', privacy: 'Solo rutas autorizadas' },
12
+ { id: 'filesystem', label: 'Archivos locales', purpose: 'Carpetas y archivos del equipo del usuario', transport: 'stdio', activation: 'local-opt-in', privacy: 'Solo rutas autorizadas' },
13
13
  { id: 'obsidian', label: 'Obsidian / Helix Vault', purpose: 'Leer y gestionar una bóveda concreta', transport: 'stdio', activation: 'local-opt-in', privacy: 'Solo la bóveda seleccionada' },
14
14
  { id: 'vscode', label: 'VS Code', purpose: 'Trabajar con el workspace seleccionado', transport: 'stdio', activation: 'local-opt-in', privacy: 'Solo el workspace seleccionado' },
15
- { id: 'github', label: 'GitHub', purpose: 'Repositorios, issues y pull requests', transport: 'stdio', activation: 'oauth-opt-in', privacy: 'Requiere token u OAuth del usuario' },
16
- { id: 'gmail', label: 'Gmail', purpose: 'Correo y búsqueda de mensajes', transport: 'connector', activation: 'oauth-opt-in', privacy: 'Requiere OAuth explícito del usuario' },
17
- { id: 'notion', label: 'Notion', purpose: 'Páginas y bases de datos', transport: 'connector', activation: 'oauth-opt-in', privacy: 'Requiere integración autorizada' },
15
+ { id: 'cursor', label: 'Cursor', purpose: 'Proyectos y editor de código del usuario', transport: 'stdio', activation: 'local-opt-in', privacy: 'Solo el workspace autorizado' },
16
+ { id: 'github', label: 'GitHub', purpose: 'Repositorios, issues, pull requests y código', transport: 'stdio', activation: 'oauth-opt-in', privacy: 'Requiere token u OAuth del usuario' },
17
+ { id: 'linear', label: 'Linear', purpose: 'Issues, roadmap y trabajo del equipo', transport: 'connector', activation: 'oauth-opt-in', privacy: 'Requiere acceso autorizado del workspace' },
18
+ { id: 'gmail', label: 'Gmail', purpose: 'Correo, búsquedas y mensajes relevantes', transport: 'connector', activation: 'oauth-opt-in', privacy: 'Requiere OAuth explícito del usuario' },
19
+ { id: 'google-drive', label: 'Google Drive', purpose: 'Documentos y archivos compartidos', transport: 'connector', activation: 'oauth-opt-in', privacy: 'Requiere acceso autorizado a la cuenta' },
20
+ { id: 'notion', label: 'Notion', purpose: 'Páginas, bases de datos y conocimiento', transport: 'connector', activation: 'oauth-opt-in', privacy: 'Requiere integración autorizada' },
21
+ { id: 'chatgpt', label: 'ChatGPT', purpose: 'Acceso a tareas y contexto de chat del usuario', transport: 'connector', activation: 'oauth-opt-in', privacy: 'Requiere conexión autorizada del usuario' },
22
+ { id: 'claude-desktop', label: 'Claude Desktop', purpose: 'Contexto y trabajo en la app desktop de Claude', transport: 'stdio', activation: 'local-opt-in', privacy: 'Solo los recursos autorizados por el usuario' },
23
+ { id: 'word', label: 'Microsoft Word', purpose: 'Documentos y edición de texto', transport: 'stdio', activation: 'local-opt-in', privacy: 'Solo archivos autorizados' },
24
+ { id: 'excel', label: 'Microsoft Excel', purpose: 'Hojas de cálculo, análisis y tabulación', transport: 'stdio', activation: 'local-opt-in', privacy: 'Solo archivos autorizados' },
25
+ { id: 'powerpoint', label: 'Microsoft PowerPoint', purpose: 'Presentaciones y material visual', transport: 'stdio', activation: 'local-opt-in', privacy: 'Solo archivos autorizados' },
26
+ { id: 'antigravity', label: 'Antigravity', purpose: 'Workspace y flujos de automatización del usuario', transport: 'connector', activation: 'oauth-opt-in', privacy: 'Requiere conexión explícita del usuario' },
18
27
  ];
@@ -0,0 +1,23 @@
1
+ // Declaration shims for modules without bundled @types in this workspace.
2
+ // These are minimal stubs to satisfy TypeScript when explicit "types" is set.
3
+
4
+ declare module 'prompts' {
5
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
6
+ function prompts(questions: any | any[]): Promise<Record<string, any>>;
7
+ export = prompts;
8
+ }
9
+
10
+ declare module 'jpeg-js' {
11
+ interface RawImageData {
12
+ width: number;
13
+ height: number;
14
+ data: Buffer | Uint8Array;
15
+ }
16
+ interface DecodeResult extends RawImageData {
17
+ comments?: string[];
18
+ }
19
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
20
+ function decode(jpegData: Buffer | Uint8Array, opts?: Record<string, any>): DecodeResult;
21
+ function encode(rawImageData: RawImageData, quality?: number): { data: Buffer; width: number; height: number };
22
+ export { decode, encode };
23
+ }
package/src/index.ts CHANGED
@@ -32,6 +32,7 @@ import type { HelixSettings } from './client/types';
32
32
  import { handleShowApiKey } from './commands/apikey';
33
33
  import { ORANGE, CYAN, GREEN, RED, BOLD, DIM } from './ui/colors';
34
34
  import { getAsciiLogo } from './ui/banner';
35
+ import packageJson from '../package.json';
35
36
 
36
37
  async function readStdin(): Promise<string> {
37
38
  if (process.stdin.isTTY) {
@@ -73,7 +74,7 @@ const program = new Command();
73
74
  program
74
75
  .name('helix')
75
76
  .description('HELIX - Interactive Terminal CLI for Windows AI Desktop Agent')
76
- .version('0.2.14')
77
+ .version(packageJson.version)
77
78
  .option('-u, --ws-url <url>', 'URL del servidor WebSocket del agente', getWsUrl())
78
79
  .option('-t, --token <token>', 'Token de autenticación WebSocket de HELIX', getWsToken())
79
80
  .option('--plain', 'Desactivar formato enriquecido de Markdown y mostrar texto plano sin asteriscos')
@@ -14,12 +14,15 @@ export const SLASH_COMMANDS: CommandDefinition[] = [
14
14
  { command: '/skill', description: 'Listar o inspeccionar skills registradas', usage: '/skill [nombre]' },
15
15
  { command: '/mcp', description: 'Listar servidores MCP y sus herramientas', usage: '/mcp [defaults]' },
16
16
  { command: '/create-mcp', description: 'Registrar y configurar un nuevo servidor MCP' },
17
- { command: '/browser', description: 'Ver estado del navegador web o abrir URLs', usage: '/browser [open <url>]' },
17
+ { command: '/browser', description: 'Ver estado, abrir URLs o alternar la vista ASCII', usage: '/browser [open <url>|live [on|off]]' },
18
+ { command: '/subagents', description: 'Ver el panel ASCII o chatear con un subagente', usage: '/subagents [list|chat <id> <mensaje>]' },
18
19
  { command: '/config-apikey', description: 'Configurador interactivo de API Keys (OpenAI, Anthropic, Gemini, etc.)' },
19
20
  { command: '/show-apikey', description: 'Ver estado y validez de las API Keys configuradas' },
20
21
  { command: '/microphone', description: 'Ver o alternar estado del micrófono', usage: '/microphone [enable|disable]' },
21
22
  { command: '/tasks', description: 'Ver tareas activas y recientes del agente' },
23
+ { command: '/model', description: 'Ver o cambiar el modelo activo (free/local/cloud)', usage: '/model [list|set <id>|effort <low|medium|high>]' },
22
24
  { command: '/models', description: 'Listar modelos disponibles de proveedores configurados' },
25
+ { command: '/memory', description: 'Ver estadísticas del harness de aprendizaje adaptativo', usage: '/memory [stats|rules|reset]' },
23
26
  { command: '/status', description: 'Mostrar resumen del estado del sistema' },
24
27
  { command: '/providers', description: 'Ver proveedores configurados y salud de sus claves' },
25
28
  { command: '/capabilities', description: 'Ver capacidades activas: sandbox, subagentes, voz y browser' },
@@ -116,7 +119,21 @@ export class Autocompleter {
116
119
  return [hits.length ? hits : options, line];
117
120
  }
118
121
 
119
- // 3. Browser sub-arguments
122
+ // 3. Model sub-arguments
123
+ if (trimmed.startsWith('/model ')) {
124
+ const options = ['/model list', '/model set ', '/model effort low', '/model effort medium', '/model effort high'];
125
+ const hits = options.filter((o) => o.toLowerCase().startsWith(trimmed.toLowerCase()));
126
+ return [hits.length ? hits : options, line];
127
+ }
128
+
129
+ // 3b. Memory sub-arguments
130
+ if (trimmed.startsWith('/memory ')) {
131
+ const options = ['/memory stats', '/memory rules', '/memory reset'];
132
+ const hits = options.filter((o) => o.toLowerCase().startsWith(trimmed.toLowerCase()));
133
+ return [hits.length ? hits : options, line];
134
+ }
135
+
136
+ // 4. Browser sub-arguments
120
137
  if (trimmed.startsWith('/browser ')) {
121
138
  const options = ['/browser open https://'];
122
139
  return [options, line];
@@ -0,0 +1,82 @@
1
+ import jpeg from 'jpeg-js';
2
+
3
+ const ASCII_RAMP = ' .:-=+*#%@';
4
+ const MAX_FRAME_WIDTH = 76;
5
+ const MAX_FRAME_HEIGHT = 34;
6
+ const MAX_JPEG_BYTES = 560_000;
7
+
8
+ export function grayscaleToAscii(
9
+ pixels: number[][],
10
+ width = MAX_FRAME_WIDTH,
11
+ ): string {
12
+ if (!pixels.length || !pixels[0]?.length) {
13
+ throw new Error('Browser frame has no pixels to render.');
14
+ }
15
+ const boundedWidth = Math.max(1, Math.min(MAX_FRAME_WIDTH, Math.floor(width)));
16
+ const sourceHeight = pixels.length;
17
+ const sourceWidth = pixels[0].length;
18
+ const outputHeight = Math.max(
19
+ 1,
20
+ Math.min(MAX_FRAME_HEIGHT, Math.round((sourceHeight / sourceWidth) * boundedWidth * 0.5)),
21
+ );
22
+ const lines: string[] = [];
23
+
24
+ for (let y = 0; y < outputHeight; y += 1) {
25
+ const sourceY = Math.min(sourceHeight - 1, Math.floor((y / outputHeight) * sourceHeight));
26
+ let line = '';
27
+ for (let x = 0; x < boundedWidth; x += 1) {
28
+ const sourceX = Math.min(sourceWidth - 1, Math.floor((x / boundedWidth) * sourceWidth));
29
+ const value = Math.max(0, Math.min(255, pixels[sourceY][sourceX]));
30
+ const index = Math.round(((255 - value) / 255) * (ASCII_RAMP.length - 1));
31
+ line += ASCII_RAMP[index];
32
+ }
33
+ lines.push(line);
34
+ }
35
+ return lines.join('\n');
36
+ }
37
+
38
+ export function renderJpegAsAscii(base64: string, width = MAX_FRAME_WIDTH): string {
39
+ if (
40
+ typeof base64 !== 'string'
41
+ || !/^[A-Za-z0-9+/]+={0,2}$/.test(base64)
42
+ || base64.length > Math.ceil(MAX_JPEG_BYTES * 4 / 3)
43
+ ) {
44
+ throw new Error('Browser frame is not a valid, bounded Base64 JPEG.');
45
+ }
46
+ const bytes = Buffer.from(base64, 'base64');
47
+ if (bytes.length === 0 || bytes.length > MAX_JPEG_BYTES) {
48
+ throw new Error('Browser frame exceeds the supported JPEG size.');
49
+ }
50
+ const image = jpeg.decode(bytes, {
51
+ useTArray: true,
52
+ formatAsRGBA: true,
53
+ maxResolutionInMP: 4,
54
+ maxMemoryUsageInMB: 128,
55
+ });
56
+ const pixels: number[][] = [];
57
+ const outputWidth = Math.min(MAX_FRAME_WIDTH, image.width);
58
+ const outputHeight = Math.max(
59
+ 1,
60
+ Math.min(
61
+ MAX_FRAME_HEIGHT,
62
+ Math.round((image.height / image.width) * outputWidth * 0.5),
63
+ ),
64
+ );
65
+
66
+ for (let y = 0; y < outputHeight; y += 1) {
67
+ const row: number[] = [];
68
+ const sourceY = Math.min(image.height - 1, Math.floor((y / outputHeight) * image.height));
69
+ for (let x = 0; x < outputWidth; x += 1) {
70
+ const sourceX = Math.min(image.width - 1, Math.floor((x / outputWidth) * image.width));
71
+ const offset = (sourceY * image.width + sourceX) * 4;
72
+ row.push(
73
+ image.data[offset] * 0.299
74
+ + image.data[offset + 1] * 0.587
75
+ + image.data[offset + 2] * 0.114,
76
+ );
77
+ }
78
+ pixels.push(row);
79
+ }
80
+
81
+ return grayscaleToAscii(pixels, Math.min(width, outputWidth));
82
+ }
package/src/ui/repl.ts CHANGED
@@ -24,9 +24,13 @@ import { handleCreateSkill, handleListSkills } from '../commands/skills';
24
24
  import { handleCreateMcp, handleListMcp, handleMcpDefaults } from '../commands/mcp';
25
25
  import { handleMicrophoneCommand } from '../commands/mic';
26
26
  import { handleBrowserCommand } from '../commands/browser';
27
+ import { handleSubagentsCommand } from '../commands/subagents';
27
28
  import type { ConfirmationRequest } from '../client/types';
28
29
  import { formatTerminalMarkdown } from './markdown';
29
30
  import { handleCapabilities, handleProviders, handleSettings } from '../commands/diagnostics';
31
+ import { renderJpegAsAscii } from './browser-ascii';
32
+ import { handleModelCommand } from '../commands/model';
33
+ import { handleMemoryCommand } from '../commands/memory';
30
34
 
31
35
  export interface ReplOptions {
32
36
  wsUrl: string;
@@ -47,6 +51,7 @@ export class HelixRepl {
47
51
  private activeTasksCount: number = 0;
48
52
  private isProcessingPrompt: boolean = false;
49
53
  private plain: boolean = false;
54
+ private browserLiveViewEnabled: boolean = false;
50
55
 
51
56
  constructor(private options: ReplOptions) {
52
57
  this.conversationId = `cli_conv_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`;
@@ -210,6 +215,29 @@ export class HelixRepl {
210
215
  this.activeTaskId = task.task_id || null;
211
216
  }
212
217
  });
218
+
219
+ this.client.on('browser_frame', (payload: {
220
+ conversation_id?: string;
221
+ mime_type?: string;
222
+ image_base64?: string;
223
+ }) => {
224
+ if (
225
+ !this.browserLiveViewEnabled
226
+ || !payload?.conversation_id?.startsWith('cli_')
227
+ || payload.mime_type !== 'image/jpeg'
228
+ || typeof payload.image_base64 !== 'string'
229
+ ) {
230
+ return;
231
+ }
232
+ try {
233
+ process.stdout.write('\r\x1b[K');
234
+ console.log(`\n${CYAN_BOLD(' HELIX LIVE BROWSER')}`);
235
+ console.log(renderJpegAsAscii(payload.image_base64));
236
+ this._promptUser();
237
+ } catch (error) {
238
+ console.log(RED(` No se pudo representar el cuadro del navegador: ${(error as Error).message}`));
239
+ }
240
+ });
213
241
  }
214
242
 
215
243
  private async _handleConfirmationDialog(req: ConfirmationRequest): Promise<void> {
@@ -383,18 +411,48 @@ export class HelixRepl {
383
411
  break;
384
412
  }
385
413
 
386
- case '/browser':
414
+ case '/browser': {
415
+ const browserArgs = args.trim().split(/\s+/);
416
+ if (browserArgs[0]?.toLowerCase() === 'live') {
417
+ const requestedState = browserArgs[1]?.toLowerCase();
418
+ if (requestedState && !['on', 'off'].includes(requestedState)) {
419
+ console.log(YELLOW(' Uso: /browser live [on|off]\n'));
420
+ break;
421
+ }
422
+ this.browserLiveViewEnabled = requestedState
423
+ ? requestedState === 'on'
424
+ : !this.browserLiveViewEnabled;
425
+ console.log(
426
+ this.browserLiveViewEnabled
427
+ ? GREEN(' Vista ASCII del navegador activada.\n')
428
+ : YELLOW(' Vista ASCII del navegador desactivada.\n'),
429
+ );
430
+ break;
431
+ }
387
432
  await handleBrowserCommand(this.client, args);
388
433
  break;
434
+ }
435
+
436
+ case '/subagents':
437
+ await handleSubagentsCommand(this.client, args);
438
+ break;
389
439
 
390
440
  case '/tasks':
391
441
  await this._handleListTasks();
392
442
  break;
393
443
 
444
+ case '/model':
445
+ await handleModelCommand(this.client, args);
446
+ break;
447
+
394
448
  case '/models':
395
449
  await this._handleListModels();
396
450
  break;
397
451
 
452
+ case '/memory':
453
+ await handleMemoryCommand(this.client, args);
454
+ break;
455
+
398
456
  case '/status':
399
457
  await this._handleStatus();
400
458
  break;
package/tests/cli.test.ts CHANGED
@@ -7,6 +7,9 @@ import fs from 'node:fs';
7
7
  import os from 'node:os';
8
8
  import path from 'node:path';
9
9
  import { discoverUserSkillNames } from '../src/skills/user-skill-discovery';
10
+ import { renderSubagentPanel } from '../src/commands/subagents';
11
+ import { grayscaleToAscii, renderJpegAsAscii } from '../src/ui/browser-ascii';
12
+ import jpeg from 'jpeg-js';
10
13
 
11
14
  test('Autocompleter suggests slash commands', () => {
12
15
  const completer = new Autocompleter();
@@ -18,6 +21,7 @@ test('Autocompleter suggests slash commands', () => {
18
21
  assert.ok(completions.includes('/mcp'));
19
22
  assert.ok(completions.includes('/create-mcp'));
20
23
  assert.ok(completions.includes('/browser'));
24
+ assert.ok(completions.includes('/subagents'));
21
25
  assert.ok(completions.includes('/config-apikey'));
22
26
  assert.ok(completions.includes('/show-apikey'));
23
27
  assert.ok(completions.includes('/microphone'));
@@ -28,6 +32,43 @@ test('Autocompleter suggests slash commands', () => {
28
32
  assert.ok(completions.includes('/clear'));
29
33
  });
30
34
 
35
+ test('Subagent panel renders ASCII rows with current status and action', () => {
36
+ const panel = renderSubagentPanel(
37
+ [{ profile_id: 'flowbot', name: 'Flowbot', role: 'Browser automation' }],
38
+ [{
39
+ profile_id: 'flowbot',
40
+ status: 'running',
41
+ progress: 50,
42
+ current_action: 'Opening browser',
43
+ }],
44
+ );
45
+
46
+ assert.ok(panel.includes('+'));
47
+ assert.ok(panel.includes('flowbot'));
48
+ assert.ok(panel.includes('Flowbot'));
49
+ assert.ok(panel.includes('RUNNING'));
50
+ assert.ok(panel.includes('50%'));
51
+ assert.ok(panel.includes('Opening browser'));
52
+ });
53
+
54
+ test('Browser image renderer maps grayscale to a bounded ASCII frame', () => {
55
+ assert.equal(
56
+ grayscaleToAscii([[0, 255], [0, 255], [128, 64], [128, 64]], 2),
57
+ '@ \n=#',
58
+ );
59
+ });
60
+
61
+ test('Browser image renderer decodes JPEG frames and rejects invalid payloads', () => {
62
+ const image = jpeg.encode({
63
+ data: Buffer.from([255, 255, 255, 255]),
64
+ width: 1,
65
+ height: 1,
66
+ }, 80);
67
+
68
+ assert.equal(renderJpegAsAscii(image.data.toString('base64'), 1), ' ');
69
+ assert.throws(() => renderJpegAsAscii('not-a-jpeg'), /valid, bounded Base64 JPEG/);
70
+ });
71
+
31
72
  test('Autocompleter filters slash commands by prefix', () => {
32
73
  const completer = new Autocompleter();
33
74
 
package/tsconfig.json CHANGED
@@ -9,11 +9,11 @@
9
9
  "esModuleInterop": true,
10
10
  "skipLibCheck": true,
11
11
  "forceConsistentCasingInFileNames": true,
12
- // @ts-ignore - node is deprecated but required for CommonJS compatibility with current dependencies
13
12
  "moduleResolution": "node",
14
13
  "resolveJsonModule": true,
15
- "declaration": true
14
+ "declaration": true,
15
+ "types": ["node", "ws"]
16
16
  },
17
17
  "include": ["src/**/*"],
18
18
  "exclude": ["node_modules", "dist"]
19
- }
19
+ }