wispy-cli 2.3.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/wispy.mjs CHANGED
@@ -312,13 +312,63 @@ ${_bold("Usage:")}
312
312
  // ── completion sub-command ────────────────────────────────────────────────────
313
313
  if (args[0] === "completion") {
314
314
  const shell = args[1] ?? "bash";
315
- const cmds = ["ws", "trust", "where", "handoff", "skill", "teach", "improve", "dry",
316
- "deploy", "server", "node", "channel", "cron", "audit", "sync", "setup", "update",
317
- "tui", "migrate", "version", "doctor", "help", "completion", "status",
318
- "connect", "disconnect", "log"];
315
+
316
+ // Commands with descriptions for rich completions
317
+ const cmdSpecs = [
318
+ { cmd: "ws", desc: "Workstream management (list/new/switch/archive/delete/status/search)" },
319
+ { cmd: "trust", desc: "Trust level & security policies (careful/balanced/yolo)" },
320
+ { cmd: "where", desc: "Show current mode & context" },
321
+ { cmd: "handoff", desc: "Generate handoff summary & sync" },
322
+ { cmd: "skill", desc: "Skill management (list/show)" },
323
+ { cmd: "teach", desc: "Create skill from current conversation" },
324
+ { cmd: "improve", desc: "Improve an existing skill" },
325
+ { cmd: "dry", desc: "Run next command in dry-run (preview-only) mode" },
326
+ { cmd: "deploy", desc: "Deployment helpers (Dockerfile/compose/systemd/vps/…)" },
327
+ { cmd: "server", desc: "Start the Wispy API server" },
328
+ { cmd: "node", desc: "Multi-machine node management (pair/connect/list/status)" },
329
+ { cmd: "channel", desc: "Messaging channel setup (telegram/discord/slack/email/…)" },
330
+ { cmd: "cron", desc: "Scheduled tasks (list/add/remove/run/history/start)" },
331
+ { cmd: "audit", desc: "View audit log & replay sessions" },
332
+ { cmd: "log", desc: "Alias for audit" },
333
+ { cmd: "sync", desc: "Sync with remote server (push/pull/status/auto)" },
334
+ { cmd: "setup", desc: "Interactive setup wizard" },
335
+ { cmd: "init", desc: "Alias for setup" },
336
+ { cmd: "update", desc: "Update wispy-cli to latest version" },
337
+ { cmd: "tui", desc: "Launch the workspace TUI" },
338
+ { cmd: "migrate", desc: "Import from OpenClaw or other sources" },
339
+ { cmd: "version", desc: "Show version" },
340
+ { cmd: "doctor", desc: "Check system health & API keys" },
341
+ { cmd: "help", desc: "Show help (optionally for a specific command)" },
342
+ { cmd: "completion", desc: "Print shell completion script (bash/zsh/fish)" },
343
+ { cmd: "status", desc: "Show wispy status & remote connection info" },
344
+ { cmd: "connect", desc: "Connect to a remote Wispy server" },
345
+ { cmd: "disconnect", desc: "Disconnect from remote server (go back to local)" },
346
+ ];
347
+
348
+ // Sub-command completions for nested commands
349
+ const subCmds = {
350
+ ws: ["new", "switch", "archive", "delete", "status", "search"],
351
+ trust: ["careful", "balanced", "yolo", "log"],
352
+ skill: ["list", "show"],
353
+ deploy: ["init", "dockerfile", "compose", "systemd", "railway", "fly", "render", "modal", "daytona", "vps", "status"],
354
+ cron: ["list", "add", "remove", "run", "history", "start"],
355
+ audit: ["replay", "export", "--today", "--session", "--tool", "--limit"],
356
+ channel: ["setup", "list", "test"],
357
+ node: ["pair", "connect", "list", "status", "remove"],
358
+ sync: ["push", "pull", "status", "auto"],
359
+ server: ["start", "stop", "status"],
360
+ completion: ["bash", "zsh", "fish"],
361
+ help: cmdSpecs.map(c => c.cmd),
362
+ };
363
+
364
+ const cmds = cmdSpecs.map(c => c.cmd);
319
365
  const flags = ["--help", "--version", "--debug", "--serve", "--telegram", "--discord", "--slack"];
320
366
 
321
367
  if (shell === "bash") {
368
+ const subCmdsStr = Object.entries(subCmds)
369
+ .map(([k, v]) => ` ${k}) COMPREPLY=( $(compgen -W "${v.join(' ')}" -- "\${cur}") ) ;;`)
370
+ .join('\n');
371
+
322
372
  console.log(`# wispy bash completion
323
373
  # Add to ~/.bashrc: eval "$(wispy completion bash)"
324
374
  _wispy_completion() {
@@ -331,30 +381,69 @@ _wispy_completion() {
331
381
 
332
382
  if [[ \${COMP_CWORD} -eq 1 ]]; then
333
383
  COMPREPLY=( $(compgen -W "\${commands} \${flags}" -- "\${cur}") )
384
+ elif [[ \${COMP_CWORD} -eq 2 ]]; then
385
+ case "\${prev}" in
386
+ ${subCmdsStr}
387
+ esac
334
388
  fi
335
389
  }
336
390
  complete -F _wispy_completion wispy`);
391
+
337
392
  } else if (shell === "zsh") {
393
+ const cmdDescLines = cmdSpecs.map(c => ` '${c.cmd}:${c.desc.replace(/'/g, "''")}'`).join('\n');
394
+ const subCmdLines = Object.entries(subCmds)
395
+ .map(([k, v]) => ` (${k}) _values '${k} commands' ${v.map(s => `'${s}'`).join(' ')} ;;`)
396
+ .join('\n');
397
+
338
398
  console.log(`# wispy zsh completion
339
399
  # Add to ~/.zshrc: eval "$(wispy completion zsh)"
400
+ # Or for permanent use: wispy completion zsh > /usr/local/share/zsh/site-functions/_wispy
340
401
  _wispy() {
402
+ local context state state_descr line
403
+ typeset -A opt_args
404
+
341
405
  local -a commands
342
406
  commands=(
343
- ${cmds.map(c => `'${c}'`).join("\n ")}
407
+ ${cmdDescLines}
344
408
  )
345
- local -a flags
346
- flags=(${flags.map(f => `'${f}'`).join(" ")})
409
+
347
410
  _arguments -C \\
348
- '1:command:->cmds' \\
411
+ '(- *)'{-h,--help}'[Show help]' \\
412
+ '(- *)'{-v,--version}'[Show version]' \\
413
+ '--debug[Enable verbose logs]' \\
414
+ '--serve[Start all channel bots]' \\
415
+ '--telegram[Start Telegram bot]' \\
416
+ '--discord[Start Discord bot]' \\
417
+ '--slack[Start Slack bot]' \\
418
+ '1:command:->cmd' \\
349
419
  '*::arg:->args'
420
+
350
421
  case $state in
351
- cmds) _describe 'wispy commands' commands ;;
422
+ cmd)
423
+ _describe 'wispy commands' commands
424
+ ;;
425
+ args)
426
+ case $line[1] in
427
+ ${subCmdLines}
428
+ esac
429
+ ;;
352
430
  esac
353
431
  }
354
432
  compdef _wispy wispy`);
433
+
355
434
  } else if (shell === "fish") {
356
- const completions = cmds.map(c => `complete -c wispy -f -n '__fish_use_subcommand' -a '${c}'`).join("\n");
357
- console.log(`# wispy fish completion\n# Save to ~/.config/fish/completions/wispy.fish\n${completions}`);
435
+ const mainComps = cmdSpecs
436
+ .map(c => `complete -c wispy -f -n '__fish_use_subcommand' -a '${c.cmd}' -d '${c.desc.replace(/'/g, "\\'")}'`)
437
+ .join("\n");
438
+ const subComps = Object.entries(subCmds)
439
+ .flatMap(([k, v]) => v.map(s => `complete -c wispy -f -n '__fish_seen_subcommand_from ${k}' -a '${s}'`))
440
+ .join("\n");
441
+
442
+ console.log(`# wispy fish completion
443
+ # Save to ~/.config/fish/completions/wispy.fish
444
+ ${mainComps}
445
+ ${subComps}`);
446
+
358
447
  } else {
359
448
  console.error(_red(`❌ Unknown shell: ${shell}. Use: bash, zsh, or fish`));
360
449
  process.exit(2);
@@ -635,6 +724,13 @@ if (args[0] === "setup" || args[0] === "init") {
635
724
  await wizard.run();
636
725
  }
637
726
  } catch (e) { friendlyError(e); }
727
+
728
+ // Shell completion tip
729
+ console.log(_dim(`
730
+ 💡 Tip: Enable tab completion in your shell:
731
+ eval "$(wispy completion zsh)" # zsh — add to ~/.zshrc for permanent
732
+ eval "$(wispy completion bash)" # bash — add to ~/.bashrc for permanent
733
+ `));
638
734
  process.exit(0);
639
735
  }
640
736
 
package/core/migrate.mjs CHANGED
@@ -71,6 +71,7 @@ export class OpenClawMigrator {
71
71
  await this._migrateWorkspaceFiles(dryRun);
72
72
  await this._migrateCronJobs(dryRun);
73
73
  await this._migrateChannels(dryRun);
74
+ await this._migrateApiKeys(dryRun);
74
75
  }
75
76
 
76
77
  return {
@@ -274,9 +275,11 @@ export class OpenClawMigrator {
274
275
  } catch { /* empty */ }
275
276
 
276
277
  // Extract Telegram config
277
- const telegramToken = config.telegram?.token
278
+ const telegramToken = config.channels?.telegram?.botToken
279
+ ?? config.telegram?.token
278
280
  ?? config.channels?.telegram?.token
279
- ?? config.plugins?.telegram?.token;
281
+ ?? config.plugins?.telegram?.token
282
+ ?? config.plugins?.entries?.telegram?.config?.botToken;
280
283
 
281
284
  if (telegramToken && !wispyChannels.telegram) {
282
285
  wispyChannels.telegram = { token: telegramToken };
@@ -302,6 +305,83 @@ export class OpenClawMigrator {
302
305
  }
303
306
  }
304
307
 
308
+ // ── API keys migration ───────────────────────────────────────────────────────
309
+
310
+ async _migrateApiKeys(dryRun) {
311
+ // Read API keys from environment (same as OpenClaw uses)
312
+ const keyMap = {
313
+ google: { env: ["GOOGLE_AI_KEY", "GOOGLE_GENERATIVE_AI_KEY", "GEMINI_API_KEY"], model: "gemini-2.5-flash" },
314
+ anthropic: { env: ["ANTHROPIC_API_KEY"], model: "claude-sonnet-4-20250514" },
315
+ openai: { env: ["OPENAI_API_KEY"], model: "gpt-4o" },
316
+ groq: { env: ["GROQ_API_KEY"], model: "llama-3.3-70b-versatile" },
317
+ deepseek: { env: ["DEEPSEEK_API_KEY"], model: "deepseek-chat" },
318
+ openrouter:{ env: ["OPENROUTER_API_KEY"], model: "anthropic/claude-sonnet-4-20250514" },
319
+ };
320
+
321
+ // Also check macOS keychain
322
+ const { execSync } = await import("node:child_process");
323
+ function keychainGet(service, account) {
324
+ try {
325
+ return execSync(`security find-generic-password -s "${service}" -a "${account}" -w 2>/dev/null`, { encoding: "utf8" }).trim();
326
+ } catch { return null; }
327
+ }
328
+
329
+ const keychainMap = {
330
+ google: { service: "google-ai-key", account: "poropo" },
331
+ anthropic: { service: "anthropic-api-key", account: "poropo" },
332
+ openai: { service: "openai-api-key", account: "poropo" },
333
+ };
334
+
335
+ const configPath = join(this.wispyDir, "config.json");
336
+ let config = {};
337
+ try { config = JSON.parse(await readFile(configPath, "utf8")); } catch { /* empty */ }
338
+ if (!config.providers) config.providers = {};
339
+
340
+ let found = 0;
341
+ for (const [provider, info] of Object.entries(keyMap)) {
342
+ if (config.providers[provider]?.apiKey) continue; // already configured
343
+
344
+ // Check env vars
345
+ let key = null;
346
+ for (const envName of info.env) {
347
+ if (process.env[envName]) { key = process.env[envName]; break; }
348
+ }
349
+
350
+ // Check keychain (macOS)
351
+ if (!key && keychainMap[provider]) {
352
+ key = keychainGet(keychainMap[provider].service, keychainMap[provider].account);
353
+ }
354
+
355
+ // Check ~/.zshenv for exports
356
+ if (!key) {
357
+ try {
358
+ const zshenv = await readFile(join(homedir(), ".zshenv"), "utf8");
359
+ for (const envName of info.env) {
360
+ const match = zshenv.match(new RegExp(`export\\s+${envName}=["']?([^"'\\n]+)`));
361
+ if (match) { key = match[1]; break; }
362
+ }
363
+ } catch { /* no zshenv */ }
364
+ }
365
+
366
+ if (key) {
367
+ found++;
368
+ this.report.apiKeys = this.report.apiKeys || [];
369
+ this.report.apiKeys.push({ provider, masked: key.slice(0, 6) + "..." + key.slice(-4) });
370
+
371
+ if (!dryRun) {
372
+ config.providers[provider] = { apiKey: key, model: info.model };
373
+ if (!config.defaultProvider) config.defaultProvider = provider;
374
+ }
375
+ }
376
+ }
377
+
378
+ if (found > 0 && !dryRun) {
379
+ config.onboarded = true;
380
+ await mkdir(this.wispyDir, { recursive: true });
381
+ await writeFile(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
382
+ }
383
+ }
384
+
305
385
  // ── Report formatting ─────────────────────────────────────────────────────────
306
386
 
307
387
  formatReport() {
@@ -345,6 +425,13 @@ export class OpenClawMigrator {
345
425
  }
346
426
  }
347
427
 
428
+ if (r.apiKeys?.length > 0) {
429
+ lines.push(`🔑 API Keys (${r.apiKeys.length}):`);
430
+ for (const k of r.apiKeys) {
431
+ lines.push(` import: ${k.provider} (${k.masked})`);
432
+ }
433
+ }
434
+
348
435
  if (r.errors.length > 0) {
349
436
  lines.push(`⚠️ Errors (${r.errors.length}):`);
350
437
  for (const e of r.errors) {
@@ -0,0 +1,112 @@
1
+ /**
2
+ * command-registry.mjs — Shared command registry for Wispy
3
+ *
4
+ * Used by REPL completer, TUI CommandPalette, and shell completion.
5
+ * Keep this file in sync whenever new slash commands are added.
6
+ */
7
+
8
+ export const COMMANDS = [
9
+ // ── Workstream ──────────────────────────────────────────────────────────────
10
+ { cmd: '/ws', desc: 'List workstreams', category: 'Workstream' },
11
+ { cmd: '/ws new <name>', desc: 'Create workstream', category: 'Workstream' },
12
+ { cmd: '/ws <name>', desc: 'Switch workstream', category: 'Workstream' },
13
+ { cmd: '/ws status', desc: 'All workstreams overview', category: 'Workstream' },
14
+ { cmd: '/ws search <query>', desc: 'Search across workstreams', category: 'Workstream' },
15
+ { cmd: '/ws archive <name>', desc: 'Archive workstream', category: 'Workstream' },
16
+ { cmd: '/ws delete <name>', desc: 'Delete workstream', category: 'Workstream' },
17
+
18
+ // ── Trust ───────────────────────────────────────────────────────────────────
19
+ { cmd: '/trust', desc: 'Security level & policies', category: 'Trust' },
20
+ { cmd: '/trust <level>', desc: 'Set level (careful/balanced/yolo)', category: 'Trust' },
21
+ { cmd: '/trust log', desc: 'Audit log', category: 'Trust' },
22
+ { cmd: '/dry', desc: 'Toggle dry-run mode', category: 'Trust' },
23
+ { cmd: '/receipt', desc: 'Last execution receipt', category: 'Trust' },
24
+ { cmd: '/replay', desc: 'Replay current session', category: 'Trust' },
25
+
26
+ // ── Continuity ──────────────────────────────────────────────────────────────
27
+ { cmd: '/where', desc: 'Show current mode', category: 'Continuity' },
28
+ { cmd: '/handoff', desc: 'Sync + context summary', category: 'Continuity' },
29
+ { cmd: '/sync', desc: 'Sync with remote', category: 'Continuity' },
30
+
31
+ // ── Skills ──────────────────────────────────────────────────────────────────
32
+ { cmd: '/skills', desc: 'List learned skills', category: 'Skills' },
33
+ { cmd: '/teach <name>', desc: 'Create skill from conversation', category: 'Skills' },
34
+ { cmd: '/improve <name>', desc: 'Improve a skill', category: 'Skills' },
35
+
36
+ // ── Session ─────────────────────────────────────────────────────────────────
37
+ { cmd: '/clear', desc: 'Reset conversation', category: 'Session' },
38
+ { cmd: '/model', desc: 'Show/change model', category: 'Session' },
39
+ { cmd: '/cost', desc: 'Token usage & cost', category: 'Session' },
40
+ { cmd: '/compact', desc: 'Compress conversation', category: 'Session' },
41
+ { cmd: '/sessions', desc: 'List all sessions', category: 'Session' },
42
+ { cmd: '/history', desc: 'Show conversation length', category: 'Session' },
43
+
44
+ // ── Memory ──────────────────────────────────────────────────────────────────
45
+ { cmd: '/remember <text>', desc: 'Save to memory', category: 'Memory' },
46
+ { cmd: '/recall <query>', desc: 'Search memories', category: 'Memory' },
47
+ { cmd: '/memories', desc: 'List all memories', category: 'Memory' },
48
+ { cmd: '/forget <key>', desc: 'Delete memory', category: 'Memory' },
49
+
50
+ // ── Agents ──────────────────────────────────────────────────────────────────
51
+ { cmd: '/agents', desc: 'List sub-agents', category: 'Agents' },
52
+ { cmd: '/agent <id>', desc: 'Agent details', category: 'Agents' },
53
+ { cmd: '/kill <id>', desc: 'Kill sub-agent', category: 'Agents' },
54
+
55
+ // ── MCP ─────────────────────────────────────────────────────────────────────
56
+ { cmd: '/mcp', desc: 'MCP server management', category: 'MCP' },
57
+ { cmd: '/mcp list', desc: 'List MCP servers', category: 'MCP' },
58
+ { cmd: '/mcp connect', desc: 'Connect MCP server', category: 'MCP' },
59
+ { cmd: '/mcp disconnect', desc: 'Disconnect MCP server', category: 'MCP' },
60
+ { cmd: '/mcp reload', desc: 'Reload MCP servers', category: 'MCP' },
61
+ { cmd: '/mcp config', desc: 'Show MCP config', category: 'MCP' },
62
+
63
+ // ── Permissions & Audit ─────────────────────────────────────────────────────
64
+ { cmd: '/permissions', desc: 'Show permission policies', category: 'Trust' },
65
+ { cmd: '/permit <tool> <level>', desc: 'Change tool policy (auto|notify|approve)', category: 'Trust' },
66
+ { cmd: '/audit', desc: 'Show recent audit events', category: 'Trust' },
67
+
68
+ // ── Quick aliases ───────────────────────────────────────────────────────────
69
+ { cmd: '/o', desc: 'Overview (alias for /ws status)', category: 'Quick' },
70
+ { cmd: '/w', desc: 'Workstreams list (alias)', category: 'Quick' },
71
+ { cmd: '/a', desc: 'Agents list (alias)', category: 'Quick' },
72
+ { cmd: '/m', desc: 'Memories (alias)', category: 'Quick' },
73
+ { cmd: '/t', desc: 'Timeline / recent actions (alias)', category: 'Quick' },
74
+ { cmd: '/s', desc: 'Sync status (alias)', category: 'Quick' },
75
+ { cmd: '/d', desc: 'Dry-run toggle (alias)', category: 'Quick' },
76
+
77
+ // ── Meta ─────────────────────────────────────────────────────────────────────
78
+ { cmd: '/help', desc: 'Show all commands', category: 'Meta' },
79
+ { cmd: '/quit', desc: 'Exit wispy', category: 'Meta' },
80
+ { cmd: '/exit', desc: 'Exit wispy (alias)', category: 'Meta' },
81
+ { cmd: '/provider', desc: 'Show current provider & model', category: 'Meta' },
82
+ { cmd: '/overview', desc: 'Director view of all workstreams', category: 'Meta' },
83
+ { cmd: '/search <keyword>', desc: 'Search across workstreams', category: 'Meta' },
84
+ ];
85
+
86
+ /**
87
+ * Returns COMMANDS merged with dynamically loaded skills.
88
+ * skillManager is optional — if null, returns base COMMANDS.
89
+ */
90
+ export function getCommandsWithSkills(skillManager) {
91
+ const skills = skillManager?.list?.() ?? [];
92
+ return [
93
+ ...COMMANDS,
94
+ ...skills.map(s => ({
95
+ cmd: `/${s.name}`,
96
+ desc: s.description ?? 'Learned skill',
97
+ category: 'Skill',
98
+ })),
99
+ ];
100
+ }
101
+
102
+ /**
103
+ * Filter commands that start with the given prefix.
104
+ * Returns up to `limit` results.
105
+ */
106
+ export function filterCommands(prefix, commands = COMMANDS, limit = 10) {
107
+ if (!prefix) return [];
108
+ const lower = prefix.toLowerCase();
109
+ return commands
110
+ .filter(c => c.cmd.toLowerCase().startsWith(lower))
111
+ .slice(0, limit);
112
+ }
@@ -13,11 +13,13 @@
13
13
  import os from "node:os";
14
14
  import path from "node:path";
15
15
  import { createInterface } from "node:readline";
16
- import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
16
+ import { appendFile, mkdir, readFile, writeFile, readdir, stat } from "node:fs/promises";
17
17
  import { spawn as spawnProcess } from "node:child_process";
18
18
  import { fileURLToPath } from "node:url";
19
19
  import { statSync } from "node:fs";
20
20
 
21
+ import { COMMANDS, getCommandsWithSkills, filterCommands } from "./command-registry.mjs";
22
+
21
23
  import {
22
24
  WispyEngine,
23
25
  loadConfig,
@@ -1084,6 +1086,136 @@ ${bold("Permissions & Audit (v1.1):")}
1084
1086
  // Interactive REPL
1085
1087
  // ---------------------------------------------------------------------------
1086
1088
 
1089
+ // ---------------------------------------------------------------------------
1090
+ // REPL autocomplete helpers
1091
+ // ---------------------------------------------------------------------------
1092
+
1093
+ /**
1094
+ * Build a combined command list for the completer.
1095
+ * Synchronous-safe: uses cached skills list if engine is available.
1096
+ */
1097
+ let _cachedCommands = null;
1098
+ function getCachedCommands(engine) {
1099
+ if (_cachedCommands) return _cachedCommands;
1100
+ try {
1101
+ const skills = engine?.skills?.listSync?.() ?? [];
1102
+ _cachedCommands = getCommandsWithSkills({ list: () => skills });
1103
+ } catch {
1104
+ _cachedCommands = COMMANDS;
1105
+ }
1106
+ return _cachedCommands;
1107
+ }
1108
+
1109
+ /**
1110
+ * Path completer: expands file-system paths starting with / ~ or ./
1111
+ */
1112
+ async function pathCompleter(partial) {
1113
+ try {
1114
+ let dir, prefix;
1115
+ if (partial === '' || partial === '.') {
1116
+ dir = process.cwd(); prefix = partial;
1117
+ } else if (partial.startsWith('~/')) {
1118
+ const expanded = path.join(os.homedir(), partial.slice(2));
1119
+ dir = path.dirname(expanded);
1120
+ prefix = partial;
1121
+ } else {
1122
+ dir = path.dirname(partial) || '.';
1123
+ if (dir === '.') dir = process.cwd();
1124
+ prefix = partial;
1125
+ }
1126
+ const base = path.basename(partial);
1127
+ const entries = await readdir(dir).catch(() => []);
1128
+ const hits = entries
1129
+ .filter(e => e.startsWith(base))
1130
+ .map(e => {
1131
+ const full = path.join(dir, e);
1132
+ try {
1133
+ const s = statSync(full);
1134
+ return s.isDirectory() ? full + '/' : full;
1135
+ } catch { return full; }
1136
+ });
1137
+ return [hits, partial];
1138
+ } catch {
1139
+ return [[], partial];
1140
+ }
1141
+ }
1142
+
1143
+ /**
1144
+ * Main readline completer: slash commands + path expansion
1145
+ */
1146
+ function makeCompleter(engine) {
1147
+ return function completer(line, callback) {
1148
+ // Slash command completion
1149
+ if (line.startsWith('/')) {
1150
+ const commands = getCachedCommands(engine);
1151
+ const hits = filterCommands(line, commands, 15);
1152
+ const completions = hits.map(c => c.cmd);
1153
+ // If no hits, return empty
1154
+ callback(null, [completions.length ? completions : [], line]);
1155
+ return;
1156
+ }
1157
+
1158
+ // Path completion (starts with /, ~/, ./, or contains a /)
1159
+ const lastWord = line.split(' ').pop() ?? '';
1160
+ if (lastWord.startsWith('/') || lastWord.startsWith('~/') || lastWord.startsWith('./') || lastWord.startsWith('../')) {
1161
+ pathCompleter(lastWord).then(([hits, orig]) => {
1162
+ callback(null, [hits, orig]);
1163
+ }).catch(() => callback(null, [[], line]));
1164
+ return;
1165
+ }
1166
+
1167
+ // No completion
1168
+ callback(null, [[], line]);
1169
+ };
1170
+ }
1171
+
1172
+ /**
1173
+ * Print a preview hint below the current line using ANSI escape sequences.
1174
+ * Clears previous hint before printing new one.
1175
+ */
1176
+ let _lastHintLines = 0;
1177
+ function showCommandHint(line, engine) {
1178
+ if (!process.stdout.isTTY) return;
1179
+ // Clear previous hint
1180
+ if (_lastHintLines > 0) {
1181
+ process.stdout.write(`\x1b[${_lastHintLines}B`); // move down past hints
1182
+ for (let i = 0; i < _lastHintLines; i++) {
1183
+ process.stdout.write('\x1b[2K\x1b[A'); // clear line, move up
1184
+ }
1185
+ _lastHintLines = 0;
1186
+ }
1187
+
1188
+ if (!line.startsWith('/') || line.length < 2) return;
1189
+
1190
+ const commands = getCachedCommands(engine);
1191
+ const matches = filterCommands(line, commands, 5);
1192
+ if (matches.length === 0) return;
1193
+
1194
+ const hints = matches.map(c => {
1195
+ const cmdPart = c.cmd.padEnd(30);
1196
+ return ` \x1b[2m${cmdPart} — ${c.desc}\x1b[0m`;
1197
+ });
1198
+
1199
+ // Save cursor, move to next line, print hints, restore cursor
1200
+ process.stdout.write('\x1b[s'); // save cursor
1201
+ process.stdout.write('\n');
1202
+ for (const h of hints) {
1203
+ process.stdout.write(`\x1b[2K${h}\n`);
1204
+ }
1205
+ process.stdout.write('\x1b[u'); // restore cursor
1206
+ _lastHintLines = hints.length;
1207
+ }
1208
+
1209
+ function clearCommandHint() {
1210
+ if (!process.stdout.isTTY || _lastHintLines === 0) return;
1211
+ process.stdout.write('\x1b[s');
1212
+ for (let i = 0; i < _lastHintLines; i++) {
1213
+ process.stdout.write('\n\x1b[2K');
1214
+ }
1215
+ process.stdout.write('\x1b[u');
1216
+ _lastHintLines = 0;
1217
+ }
1218
+
1087
1219
  async function runRepl(engine) {
1088
1220
  // Initialize dry-run from env flag (set by `wispy dry "..."`)
1089
1221
  if (process.env.WISPY_DRY_RUN === "1") {
@@ -1104,8 +1236,26 @@ async function runRepl(engine) {
1104
1236
  output: process.stdout,
1105
1237
  prompt: green("› "),
1106
1238
  historySize: 100,
1239
+ completer: makeCompleter(engine),
1107
1240
  });
1108
1241
 
1242
+ // Show inline hints as user types
1243
+ rl.on('line', () => { clearCommandHint(); });
1244
+ if (process.stdin.isTTY) {
1245
+ process.stdin.on('keypress', (_ch, key) => {
1246
+ if (!key) return;
1247
+ // Slight delay to let readline update its line buffer
1248
+ setImmediate(() => {
1249
+ const line = rl.line ?? '';
1250
+ if (line.startsWith('/')) {
1251
+ showCommandHint(line, engine);
1252
+ } else {
1253
+ clearCommandHint();
1254
+ }
1255
+ });
1256
+ });
1257
+ }
1258
+
1109
1259
  // Dynamic prompt — shows workstream and dry-run status
1110
1260
  function updatePrompt() {
1111
1261
  const ws = ACTIVE_WORKSTREAM !== "default" ? `${cyan(ACTIVE_WORKSTREAM)} ` : "";
package/lib/wispy-tui.mjs CHANGED
@@ -14,6 +14,8 @@ import { render, Box, Text, useApp, Newline, useInput, useStdout } from "ink";
14
14
  import Spinner from "ink-spinner";
15
15
  import TextInput from "ink-text-input";
16
16
 
17
+ import { COMMANDS, filterCommands } from "./command-registry.mjs";
18
+
17
19
  import os from "node:os";
18
20
  import path from "node:path";
19
21
  import { readFile, writeFile, readdir, stat, mkdir } from "node:fs/promises";
@@ -661,6 +663,71 @@ function HelpOverlay({ onClose }) {
661
663
  );
662
664
  }
663
665
 
666
+ // ─── Command Palette (autocomplete dropdown) ──────────────────────────────────
667
+
668
+ function CommandPalette({ query, onSelect, onDismiss }) {
669
+ const [selectedIdx, setSelectedIdx] = useState(0);
670
+
671
+ const matches = filterCommands(query, COMMANDS, 8);
672
+
673
+ // Reset selection when query changes
674
+ useEffect(() => { setSelectedIdx(0); }, [query]);
675
+
676
+ useInput((input, key) => {
677
+ if (matches.length === 0) return;
678
+ if (key.upArrow) {
679
+ setSelectedIdx(i => Math.max(0, i - 1));
680
+ return;
681
+ }
682
+ if (key.downArrow) {
683
+ setSelectedIdx(i => Math.min(matches.length - 1, i + 1));
684
+ return;
685
+ }
686
+ if (key.return) {
687
+ onSelect?.(matches[selectedIdx]?.cmd ?? query);
688
+ return;
689
+ }
690
+ if (key.escape || (key.ctrl && input === 'c')) {
691
+ onDismiss?.();
692
+ return;
693
+ }
694
+ });
695
+
696
+ if (matches.length === 0) return null;
697
+
698
+ const maxCmdLen = Math.max(...matches.map(m => m.cmd.length));
699
+ const width = Math.min(60, maxCmdLen + 30);
700
+
701
+ return React.createElement(
702
+ Box, {
703
+ flexDirection: 'column',
704
+ borderStyle: 'single',
705
+ borderColor: 'cyan',
706
+ paddingX: 1,
707
+ marginBottom: 0,
708
+ },
709
+ React.createElement(
710
+ Box, { paddingX: 1 },
711
+ React.createElement(Text, { bold: true, color: 'cyan' }, '─ Commands '),
712
+ React.createElement(Text, { dimColor: true }, '↑↓ navigate · Enter select · Esc dismiss'),
713
+ ),
714
+ ...matches.map((cmd, i) => {
715
+ const isActive = i === selectedIdx;
716
+ const cmdStr = cmd.cmd.padEnd(maxCmdLen);
717
+ return React.createElement(
718
+ Box, { key: cmd.cmd },
719
+ isActive
720
+ ? React.createElement(Text, { color: 'black', backgroundColor: 'cyan' },
721
+ ` ${cmdStr} ${cmd.desc} `)
722
+ : React.createElement(Box, {},
723
+ React.createElement(Text, { color: 'cyan' }, ` ${cmd.cmd}`),
724
+ React.createElement(Text, { dimColor: true }, ' '.repeat(Math.max(1, maxCmdLen - cmd.cmd.length + 2)) + cmd.desc),
725
+ ),
726
+ );
727
+ }),
728
+ );
729
+ }
730
+
664
731
  // ─── Input Area ───────────────────────────────────────────────────────────────
665
732
 
666
733
  function InputArea({ value, onChange, onSubmit, loading, workstream, view }) {
@@ -817,6 +884,9 @@ function WispyWorkspaceApp({ engine, initialWorkstream }) {
817
884
  const [inputValue, setInputValue] = useState("");
818
885
  const [loading, setLoading] = useState(false);
819
886
 
887
+ // Command palette (autocomplete)
888
+ const [showPalette, setShowPalette] = useState(false);
889
+
820
890
  // Engine stats
821
891
  const [model, setModel] = useState(engine.model ?? "?");
822
892
  const [provider, setProvider] = useState(engine.provider ?? "?");
@@ -1063,12 +1133,34 @@ function WispyWorkspaceApp({ engine, initialWorkstream }) {
1063
1133
  }
1064
1134
  }, [pendingApproval]);
1065
1135
 
1136
+ // ── Palette visibility: show when typing slash commands ──
1137
+ const handleInputChange = useCallback((val) => {
1138
+ setInputValue(val);
1139
+ if (val.startsWith('/') && val.length >= 2) {
1140
+ setShowPalette(true);
1141
+ } else {
1142
+ setShowPalette(false);
1143
+ }
1144
+ }, []);
1145
+
1146
+ const handlePaletteSelect = useCallback((cmd) => {
1147
+ // Fill in base command (strip template args like <name>)
1148
+ const base = cmd.replace(/<[^>]+>/g, '').trimEnd();
1149
+ setInputValue(base);
1150
+ setShowPalette(false);
1151
+ }, []);
1152
+
1153
+ const handlePaletteDismiss = useCallback(() => {
1154
+ setShowPalette(false);
1155
+ }, []);
1156
+
1066
1157
  // ── Message submit ──
1067
1158
  const handleSubmit = useCallback(async (value) => {
1068
1159
  if (pendingApproval) return;
1069
1160
  const input = value.trim();
1070
1161
  if (!input || loading) return;
1071
1162
  setInputValue("");
1163
+ setShowPalette(false);
1072
1164
 
1073
1165
  // Switch to chat if not there
1074
1166
  if (view !== "chat") setView("chat");
@@ -1261,11 +1353,20 @@ function WispyWorkspaceApp({ engine, initialWorkstream }) {
1261
1353
  ? React.createElement(TimelineBar, { events: timeline })
1262
1354
  : null,
1263
1355
 
1356
+ // Command palette (autocomplete) — shown above input when typing /command
1357
+ !showHelp && showPalette && inputValue.startsWith('/')
1358
+ ? React.createElement(CommandPalette, {
1359
+ query: inputValue,
1360
+ onSelect: handlePaletteSelect,
1361
+ onDismiss: handlePaletteDismiss,
1362
+ })
1363
+ : null,
1364
+
1264
1365
  // Input area (always at bottom; hidden when help shown)
1265
1366
  !showHelp
1266
1367
  ? React.createElement(InputArea, {
1267
1368
  value: inputValue,
1268
- onChange: setInputValue,
1369
+ onChange: handleInputChange,
1269
1370
  onSubmit: handleSubmit,
1270
1371
  loading,
1271
1372
  workstream: activeWorkstream,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wispy-cli",
3
- "version": "2.3.0",
3
+ "version": "2.4.0",
4
4
  "description": "🌿 Wispy — AI workspace assistant with trustworthy execution (harness, receipts, approvals, diffs)",
5
5
  "license": "MIT",
6
6
  "author": "Minseo & Poropo",