wispy-cli 2.7.26 → 2.7.28

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
@@ -1447,12 +1447,13 @@ if (command === "update" || command === "upgrade") {
1447
1447
  // ── Guard: known commands that fall through to REPL ───────────────────────────
1448
1448
 
1449
1449
  const REPL_COMMANDS = new Set(["server", "overview", undefined]);
1450
+ const LATE_COMMANDS = new Set(["stop", "status"]); // handled after the guard
1450
1451
  // If command looks like a flag (starts with -), it's a REPL arg (e.g., wispy -w project)
1451
1452
  // If command is undefined (no args), start REPL
1452
1453
  // If command is a known REPL command, fall through
1453
1454
  // Otherwise, if it looks like an unknown command (not a chat message), warn
1454
1455
 
1455
- if (command && !REPL_COMMANDS.has(command) && !command.startsWith("-")) {
1456
+ if (command && !REPL_COMMANDS.has(command) && !LATE_COMMANDS.has(command) && !command.startsWith("-")) {
1456
1457
  // Check if it could be a chat message (contains spaces, is long, etc.)
1457
1458
  const fullPrompt = args.join(" ");
1458
1459
  if (fullPrompt.length < 30 && !fullPrompt.includes(" ") && /^[a-z][a-z0-9-]*$/i.test(command)) {
@@ -1464,6 +1465,74 @@ if (command && !REPL_COMMANDS.has(command) && !command.startsWith("-")) {
1464
1465
  }
1465
1466
  }
1466
1467
 
1468
+ // ── Stop / Status ─────────────────────────────────────────────────────────────
1469
+
1470
+ if (command === "stop") {
1471
+ // Kill any running wispy processes (server, TUI) except this one
1472
+ const { execSync } = await import("node:child_process");
1473
+ const myPid = process.pid;
1474
+ try {
1475
+ const pids = execSync("pgrep -f 'wispy-tui\\|wispy.mjs\\|wispy-cli' 2>/dev/null || true", { encoding: "utf8" })
1476
+ .trim().split("\n").filter(p => p && Number(p) !== myPid);
1477
+ if (pids.length === 0) {
1478
+ console.log(" No running wispy processes found.");
1479
+ } else {
1480
+ for (const pid of pids) {
1481
+ try { process.kill(Number(pid), "SIGTERM"); } catch {}
1482
+ }
1483
+ console.log(` Stopped ${pids.length} wispy process(es).`);
1484
+ }
1485
+ } catch {
1486
+ console.log(" No running wispy processes found.");
1487
+ }
1488
+ // Also try stopping server via PID file
1489
+ try {
1490
+ const pidFile = join(rootDir, "..", "..", ".wispy", "server.pid");
1491
+ const { readFile: rf } = await import("node:fs/promises");
1492
+ const serverPid = (await rf(pidFile, "utf8")).trim();
1493
+ if (serverPid) {
1494
+ try { process.kill(Number(serverPid), "SIGTERM"); } catch {}
1495
+ console.log(` Server (PID ${serverPid}) stopped.`);
1496
+ }
1497
+ } catch {}
1498
+ process.exit(0);
1499
+ }
1500
+
1501
+ if (command === "status") {
1502
+ const pkg = JSON.parse(await readFile(join(rootDir, "package.json"), "utf8"));
1503
+ console.log(`\n wispy-cli v${pkg.version}`);
1504
+
1505
+ // Check server
1506
+ try {
1507
+ const port = process.env.AWOS_PORT ?? "8090";
1508
+ const resp = await fetch(`http://127.0.0.1:${port}/api/health`, { signal: AbortSignal.timeout(2000) });
1509
+ if (resp.ok) console.log(` Server: running (port ${port})`);
1510
+ else console.log(" Server: not running");
1511
+ } catch {
1512
+ console.log(" Server: not running");
1513
+ }
1514
+
1515
+ // Check browser bridge
1516
+ try {
1517
+ const resp = await fetch("http://127.0.0.1:3000/health", { signal: AbortSignal.timeout(2000) });
1518
+ if (resp.ok) console.log(" Browser: connected");
1519
+ else console.log(" Browser: not running");
1520
+ } catch {
1521
+ console.log(" Browser: not running");
1522
+ }
1523
+
1524
+ // Check running processes
1525
+ const { execSync } = await import("node:child_process");
1526
+ try {
1527
+ const count = execSync("pgrep -fc 'wispy-tui\\|wispy.mjs' 2>/dev/null || echo 0", { encoding: "utf8" }).trim();
1528
+ console.log(` Processes: ${count} wispy instance(s)`);
1529
+ } catch {
1530
+ console.log(" Processes: unknown");
1531
+ }
1532
+ console.log("");
1533
+ process.exit(0);
1534
+ }
1535
+
1467
1536
  if (command === "server" || command === "overview") {
1468
1537
  // Already set up env flags above, fall through to REPL
1469
1538
  }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * readline-input.mjs — Readline-based input handler for proper Korean/CJK IME support
3
+ *
4
+ * Ink's useInput hook processes raw bytes and breaks Korean IME composition.
5
+ * This module uses Node.js readline which handles CJK IME at a higher level.
6
+ *
7
+ * HYBRID APPROACH: Ink renders the UI, readline handles all input.
8
+ */
9
+
10
+ import { createInterface, emitKeypressEvents } from "node:readline";
11
+
12
+ export class ReadlineInput {
13
+ constructor({
14
+ onSubmit,
15
+ onUpdate,
16
+ onKeypress,
17
+ onCtrlC,
18
+ onCtrlL,
19
+ prompt = "> ",
20
+ } = {}) {
21
+ this.onSubmit = onSubmit;
22
+ this.onUpdate = onUpdate;
23
+ this.onKeypress = onKeypress;
24
+ this.onCtrlC = onCtrlC;
25
+ this.onCtrlL = onCtrlL;
26
+ this.prompt = prompt;
27
+ this._rl = null;
28
+ this._currentLine = "";
29
+ this._keypressListener = null;
30
+ }
31
+
32
+ start() {
33
+ // CRITICAL: Use readline which handles IME composition properly
34
+ this._rl = createInterface({
35
+ input: process.stdin,
36
+ output: null, // We don't want readline to write to stdout — Ink does that
37
+ terminal: true,
38
+ prompt: "",
39
+ });
40
+
41
+ // readline's 'line' event fires after IME composition is complete
42
+ this._rl.on("line", (line) => {
43
+ this._currentLine = "";
44
+ this.onSubmit?.(line);
45
+ });
46
+
47
+ process.stdin.setEncoding("utf8");
48
+
49
+ // Wire keypress events through readline's parser for proper CJK handling
50
+ emitKeypressEvents(process.stdin, this._rl);
51
+
52
+ if (process.stdin.isTTY) {
53
+ process.stdin.setRawMode(false); // KEY: let readline handle raw mode
54
+ }
55
+
56
+ this._keypressListener = (str, key) => {
57
+ if (!key) return;
58
+
59
+ // Ctrl+C
60
+ if (key.ctrl && key.name === "c") {
61
+ this.onCtrlC?.();
62
+ return;
63
+ }
64
+
65
+ // Ctrl+L
66
+ if (key.ctrl && key.name === "l") {
67
+ this.onCtrlL?.();
68
+ return;
69
+ }
70
+
71
+ // General keypress for parent routing (Tab, arrows, escape, single chars)
72
+ this.onKeypress?.(str, key);
73
+
74
+ // Update current line for live preview
75
+ // readline.line contains the current in-progress line
76
+ if (this._rl) {
77
+ const currentLine = this._rl.line ?? "";
78
+ if (currentLine !== this._currentLine) {
79
+ this._currentLine = currentLine;
80
+ this.onUpdate?.(currentLine);
81
+ }
82
+ }
83
+ };
84
+
85
+ process.stdin.on("keypress", this._keypressListener);
86
+ }
87
+
88
+ stop() {
89
+ if (this._keypressListener) {
90
+ process.stdin.off("keypress", this._keypressListener);
91
+ this._keypressListener = null;
92
+ }
93
+ if (this._rl) {
94
+ this._rl.close();
95
+ this._rl = null;
96
+ }
97
+ this._currentLine = "";
98
+ }
99
+
100
+ getLine() {
101
+ return this._rl?.line ?? this._currentLine;
102
+ }
103
+ }
package/lib/wispy-tui.mjs CHANGED
@@ -11,9 +11,11 @@
11
11
 
12
12
  import React, { useState, useEffect, useRef, useCallback } from "react";
13
13
  import { render, Box, Text, useApp, useInput, useStdout } from "ink";
14
+ import { PassThrough } from "node:stream";
14
15
  import Spinner from "ink-spinner";
16
+ import { ReadlineInput } from "./readline-input.mjs";
17
+ // TextInput is used only for the Memory search box (non-critical, no IME needed)
15
18
  import CJKTextInput from "./cjk-text-input.mjs";
16
- // CJKTextInput is a drop-in replacement for ink-text-input with Korean/CJK support
17
19
  const TextInput = CJKTextInput;
18
20
 
19
21
  import { COMMANDS, filterCommands } from "./command-registry.mjs";
@@ -868,8 +870,11 @@ function CommandPalette({ query, onSelect, onDismiss }) {
868
870
  }
869
871
 
870
872
  // ─── Input Area ───────────────────────────────────────────────────────────────
873
+ // NOTE: This area shows the current input value as static text.
874
+ // The REAL cursor and input are managed by ReadlineInput (readline-based) for
875
+ // proper Korean/CJK IME support. Ink rendering is decoupled from input.
871
876
 
872
- function InputArea({ value, onChange, onSubmit, loading, workstream, view }) {
877
+ function InputArea({ value, loading, workstream, view }) {
873
878
  const prompt = `${workstream} › `;
874
879
  const placeholder = loading
875
880
  ? "waiting for response..."
@@ -889,10 +894,9 @@ function InputArea({ value, onChange, onSubmit, loading, workstream, view }) {
889
894
  React.createElement(Text, { color: "yellow" }, ` ${prompt}thinking...`))
890
895
  : React.createElement(Box, {},
891
896
  React.createElement(Text, { color: view === "chat" ? "green" : "gray" }, prompt),
892
- React.createElement(TextInput, {
893
- value, onChange, onSubmit,
894
- placeholder,
895
- })),
897
+ value
898
+ ? React.createElement(Text, {}, value)
899
+ : React.createElement(Text, { dimColor: true }, placeholder)),
896
900
  );
897
901
  }
898
902
 
@@ -1221,15 +1225,18 @@ function WispyWorkspaceApp({ engine, initialWorkstream }) {
1221
1225
  return () => { engine.permissions.setApprovalHandler(null); };
1222
1226
  }, [engine]);
1223
1227
 
1224
- // ── Keyboard shortcuts ──
1225
- useInput((input, key) => {
1228
+ // ── Keyboard shortcut handler (called from ReadlineInput keypress) ──
1229
+ const handleKeypress = useCallback((str, key) => {
1226
1230
  if (pendingApproval || showHelp) return;
1227
1231
 
1228
- if (key.tab && !loading) {
1232
+ if (key.name === "tab" && !loading) {
1229
1233
  const idx = VIEWS.indexOf(view);
1230
1234
  setView(VIEWS[(idx + 1) % VIEWS.length]);
1231
1235
  return;
1232
1236
  }
1237
+
1238
+ // Only handle single-char shortcuts when no text is being typed
1239
+ const input = str ?? "";
1233
1240
  if (input === "?" && !inputValue) { setShowHelp(true); return; }
1234
1241
  if (input === "1" && !inputValue && !loading) { setView("chat"); return; }
1235
1242
  if (input === "2" && !inputValue && !loading) { setView("overview"); return; }
@@ -1244,13 +1251,6 @@ function WispyWorkspaceApp({ engine, initialWorkstream }) {
1244
1251
  if (input === "s" && !inputValue && !loading) { setView("settings"); return; }
1245
1252
  if (input === "t" && !inputValue) { setShowTimeline(prev => !prev); return; }
1246
1253
 
1247
- if (key.ctrl && input === "l") {
1248
- conversationRef.current = [];
1249
- setMessages([]);
1250
- saveConversation(activeWorkstream, []).catch(() => {});
1251
- return;
1252
- }
1253
-
1254
1254
  // Workstream number switch (7-9 and beyond 6)
1255
1255
  if (/^[7-9]$/.test(input) && !inputValue && !loading) {
1256
1256
  const idx = parseInt(input) - 1;
@@ -1264,7 +1264,38 @@ function WispyWorkspaceApp({ engine, initialWorkstream }) {
1264
1264
  exit();
1265
1265
  process.exit(0);
1266
1266
  }
1267
- });
1267
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1268
+ }, [pendingApproval, showHelp, loading, view, inputValue, workstreams, exit]);
1269
+
1270
+ // ── Wire ReadlineInput — the source of all keyboard input ──
1271
+ const rlRef = useRef(null);
1272
+
1273
+ // Keep stable refs so the ReadlineInput closure always calls current callbacks
1274
+ // without needing to restart readline.
1275
+ const handleKeypressRef = useRef(null);
1276
+ const handleSubmitRef = useRef(null);
1277
+ const handleInputChangeRef = useRef(null);
1278
+ useEffect(() => { handleKeypressRef.current = handleKeypress; }, [handleKeypress]);
1279
+
1280
+ useEffect(() => {
1281
+ const rl = new ReadlineInput({
1282
+ onSubmit: (line) => { handleSubmitRef.current?.(line); },
1283
+ onUpdate: (text) => { handleInputChangeRef.current?.(text); },
1284
+ onKeypress: (str, key) => { handleKeypressRef.current?.(str, key); },
1285
+ onCtrlC: () => { exit(); process.exit(0); },
1286
+ onCtrlL: () => {
1287
+ conversationRef.current = [];
1288
+ setMessages([]);
1289
+ saveConversation(initialWorkstream, []).catch(() => {});
1290
+ },
1291
+ prompt: `${initialWorkstream} › `,
1292
+ });
1293
+ rl.start();
1294
+ rlRef.current = rl;
1295
+ return () => rl.stop();
1296
+ // Only run once on mount — refs keep callbacks up to date.
1297
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1298
+ }, []);
1268
1299
 
1269
1300
  // ── Switch workstream ──
1270
1301
  const switchWorkstream = useCallback(async (ws) => {
@@ -1314,6 +1345,9 @@ function WispyWorkspaceApp({ engine, initialWorkstream }) {
1314
1345
  setShowPalette(val.startsWith("/") && val.length >= 2);
1315
1346
  }, []);
1316
1347
 
1348
+ // Keep refs up to date
1349
+ useEffect(() => { handleInputChangeRef.current = handleInputChange; }, [handleInputChange]);
1350
+
1317
1351
  const handlePaletteSelect = useCallback((cmd) => {
1318
1352
  const base = cmd.replace(/<[^>]+>/g, "").trimEnd();
1319
1353
  setInputValue(base);
@@ -1326,6 +1360,10 @@ function WispyWorkspaceApp({ engine, initialWorkstream }) {
1326
1360
 
1327
1361
  // ── Message submit ──
1328
1362
  const handleSubmit = useCallback(async (value) => {
1363
+ // Clear readline's internal line buffer after we consume the value
1364
+ if (rlRef.current?._rl) {
1365
+ rlRef.current._rl.line = "";
1366
+ }
1329
1367
  if (pendingApproval) return;
1330
1368
  const input = value.trim();
1331
1369
  if (!input || loading) return;
@@ -1460,6 +1498,9 @@ function WispyWorkspaceApp({ engine, initialWorkstream }) {
1460
1498
  }
1461
1499
  }, [loading, model, totalCost, totalTokens, engine, exit, pendingApproval, activeWorkstream, view, switchWorkstream]);
1462
1500
 
1501
+ // Keep ref up to date
1502
+ useEffect(() => { handleSubmitRef.current = handleSubmit; }, [handleSubmit]);
1503
+
1463
1504
  // ── Layout ──
1464
1505
  const showSidebar = termWidth >= 80;
1465
1506
  const mainWidth = showSidebar ? termWidth - SIDEBAR_WIDTH - 2 : termWidth;
@@ -1544,12 +1585,10 @@ function WispyWorkspaceApp({ engine, initialWorkstream }) {
1544
1585
  })
1545
1586
  : null,
1546
1587
 
1547
- // Input
1588
+ // Input (static display only — real input handled by ReadlineInput)
1548
1589
  !showHelp
1549
1590
  ? React.createElement(InputArea, {
1550
1591
  value: inputValue,
1551
- onChange: handleInputChange,
1552
- onSubmit: handleSubmit,
1553
1592
  loading,
1554
1593
  workstream: activeWorkstream,
1555
1594
  view,
@@ -1576,12 +1615,21 @@ async function main() {
1576
1615
 
1577
1616
  process.stdout.write("\x1b[2J\x1b[H");
1578
1617
 
1618
+ // Give Ink a fake stdin so it doesn't fight with readline for raw-mode control.
1619
+ // All actual input is handled by ReadlineInput inside WispyWorkspaceApp.
1620
+ const fakeStdin = new PassThrough();
1621
+ fakeStdin.isTTY = false;
1622
+ fakeStdin.setRawMode = () => {}; // no-op so Ink doesn't crash trying to set raw mode
1623
+
1579
1624
  const { waitUntilExit } = render(
1580
1625
  React.createElement(WispyWorkspaceApp, {
1581
1626
  engine,
1582
1627
  initialWorkstream: INITIAL_WORKSTREAM,
1583
1628
  }),
1584
- { exitOnCtrlC: true }
1629
+ {
1630
+ exitOnCtrlC: false, // ReadlineInput handles Ctrl+C
1631
+ stdin: fakeStdin,
1632
+ }
1585
1633
  );
1586
1634
 
1587
1635
  await waitUntilExit();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wispy-cli",
3
- "version": "2.7.26",
3
+ "version": "2.7.28",
4
4
  "description": "🌿 Wispy — AI workspace assistant with trustworthy execution (harness, receipts, approvals, diffs)",
5
5
  "license": "MIT",
6
6
  "author": "Minseo & Poropo",