wispy-cli 2.7.25 → 2.7.27
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/lib/cjk-text-input.mjs +15 -2
- package/lib/readline-input.mjs +103 -0
- package/lib/wispy-tui.mjs +69 -21
- package/package.json +1 -1
package/lib/cjk-text-input.mjs
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
* decoded UTF-8 characters — the real fix is handling them correctly.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import React, { useState, useEffect } from "react";
|
|
10
|
-
import { Box, Text, useInput } from "ink";
|
|
9
|
+
import React, { useState, useEffect, useRef } from "react";
|
|
10
|
+
import { Box, Text, useInput, useStdout } from "ink";
|
|
11
11
|
|
|
12
12
|
function CJKTextInput({
|
|
13
13
|
value = "",
|
|
@@ -17,6 +17,19 @@ function CJKTextInput({
|
|
|
17
17
|
focus = true,
|
|
18
18
|
showCursor = true,
|
|
19
19
|
}) {
|
|
20
|
+
const { stdout } = useStdout();
|
|
21
|
+
|
|
22
|
+
// Show real terminal cursor when focused so IME composition popup
|
|
23
|
+
// appears at the correct position (not bottom-right corner)
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (focus && stdout) {
|
|
26
|
+
stdout.write("\x1b[?25h"); // show cursor
|
|
27
|
+
return () => {
|
|
28
|
+
stdout.write("\x1b[?25l"); // hide cursor on unfocus
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
}, [focus, stdout]);
|
|
32
|
+
|
|
20
33
|
useInput(
|
|
21
34
|
(input, key) => {
|
|
22
35
|
// Skip keys handled by parent (Tab, Ctrl+C, arrows for view switching)
|
|
@@ -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,
|
|
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
|
-
|
|
893
|
-
|
|
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
|
|
1225
|
-
|
|
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
|
-
{
|
|
1629
|
+
{
|
|
1630
|
+
exitOnCtrlC: false, // ReadlineInput handles Ctrl+C
|
|
1631
|
+
stdin: fakeStdin,
|
|
1632
|
+
}
|
|
1585
1633
|
);
|
|
1586
1634
|
|
|
1587
1635
|
await waitUntilExit();
|
package/package.json
CHANGED