wispy-cli 2.3.1 → 2.4.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.
- package/bin/wispy.mjs +107 -11
- package/core/onboarding.mjs +43 -1
- package/lib/command-registry.mjs +112 -0
- package/lib/wispy-repl.mjs +151 -1
- package/lib/wispy-tui.mjs +102 -1
- package/package.json +1 -1
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
"
|
|
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
|
-
|
|
407
|
+
${cmdDescLines}
|
|
344
408
|
)
|
|
345
|
-
|
|
346
|
-
flags=(${flags.map(f => `'${f}'`).join(" ")})
|
|
409
|
+
|
|
347
410
|
_arguments -C \\
|
|
348
|
-
'
|
|
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
|
-
|
|
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
|
|
357
|
-
|
|
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/onboarding.mjs
CHANGED
|
@@ -23,7 +23,9 @@
|
|
|
23
23
|
|
|
24
24
|
import os from "node:os";
|
|
25
25
|
import path from "node:path";
|
|
26
|
-
import { mkdir, writeFile, readFile } from "node:fs/promises";
|
|
26
|
+
import { mkdir, writeFile, readFile, appendFile } from "node:fs/promises";
|
|
27
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
28
|
+
import { execSync } from "node:child_process";
|
|
27
29
|
|
|
28
30
|
import { confirm, checkbox, select, input, password } from "@inquirer/prompts";
|
|
29
31
|
|
|
@@ -219,6 +221,42 @@ function printSummaryBox(config) {
|
|
|
219
221
|
// OnboardingWizard class
|
|
220
222
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
221
223
|
|
|
224
|
+
// ── Auto-install shell completion ──────────────────────────────────────────────
|
|
225
|
+
|
|
226
|
+
async function autoInstallCompletion() {
|
|
227
|
+
try {
|
|
228
|
+
const shell = process.env.SHELL ?? "";
|
|
229
|
+
const home = os.homedir();
|
|
230
|
+
const completionLine = 'eval "$(wispy completion zsh)"';
|
|
231
|
+
const completionBash = 'eval "$(wispy completion bash)"';
|
|
232
|
+
|
|
233
|
+
if (shell.includes("zsh")) {
|
|
234
|
+
const rcPath = path.join(home, ".zshrc");
|
|
235
|
+
if (existsSync(rcPath)) {
|
|
236
|
+
const content = readFileSync(rcPath, "utf8");
|
|
237
|
+
if (!content.includes("wispy completion")) {
|
|
238
|
+
await appendFile(rcPath, `\n# Wispy CLI completion\n${completionLine}\n`);
|
|
239
|
+
console.log(dim(" ✅ Shell completion added to ~/.zshrc"));
|
|
240
|
+
}
|
|
241
|
+
} else {
|
|
242
|
+
await writeFile(rcPath, `# Wispy CLI completion\n${completionLine}\n`);
|
|
243
|
+
console.log(dim(" ✅ Shell completion added to ~/.zshrc"));
|
|
244
|
+
}
|
|
245
|
+
} else if (shell.includes("bash")) {
|
|
246
|
+
const rcPath = path.join(home, ".bashrc");
|
|
247
|
+
if (existsSync(rcPath)) {
|
|
248
|
+
const content = readFileSync(rcPath, "utf8");
|
|
249
|
+
if (!content.includes("wispy completion")) {
|
|
250
|
+
await appendFile(rcPath, `\n# Wispy CLI completion\n${completionBash}\n`);
|
|
251
|
+
console.log(dim(" ✅ Shell completion added to ~/.bashrc"));
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
} catch {
|
|
256
|
+
// Silent fail — completion is nice-to-have
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
222
260
|
export class OnboardingWizard {
|
|
223
261
|
constructor() {
|
|
224
262
|
this._config = {};
|
|
@@ -689,6 +727,10 @@ export class OnboardingWizard {
|
|
|
689
727
|
delete config._memoryRole;
|
|
690
728
|
|
|
691
729
|
await saveConfig(config);
|
|
730
|
+
|
|
731
|
+
// Auto-register shell completion
|
|
732
|
+
await autoInstallCompletion();
|
|
733
|
+
|
|
692
734
|
printSummaryBox(config);
|
|
693
735
|
|
|
694
736
|
return config;
|
|
@@ -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
|
+
}
|
package/lib/wispy-repl.mjs
CHANGED
|
@@ -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:
|
|
1369
|
+
onChange: handleInputChange,
|
|
1269
1370
|
onSubmit: handleSubmit,
|
|
1270
1371
|
loading,
|
|
1271
1372
|
workstream: activeWorkstream,
|