@sayknow-cli/tui 0.3.1 → 0.3.2
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/CHANGELOG.md +19 -0
- package/dist/types/tui.d.ts +1 -0
- package/package.json +3 -3
- package/src/components/editor.ts +25 -1
- package/src/tui.ts +59 -5
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.7.4] - 2026-06-27
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Fixed Korean IME ghost character on macOS: a Hangul syllable committed by the IME immediately after `ctrl+u` (line-delete) is now discarded, preventing it from appearing in the cleared buffer (#1150).
|
|
10
|
+
- Fixed CJK IME composition overlay positioning on macOS: the TUI now shows a steady-block hardware cursor (`\x1b[2 q`) when the soft cursor is active, anchoring the IME overlay to the correct position. Removed the synchronized-output wrapper from standalone cursor nudges, which was flushing terminal state and dismissing the overlay on every keystroke (#1150).
|
|
11
|
+
- Fixed IME preedit caret anchoring so the composition caret stays aligned with the composed text (#1178).
|
|
12
|
+
- Preserve the composer placeholder while an IME cursor is active so the prompt hint does not flicker during composition.
|
|
13
|
+
- Reflow the in-progress prompt draft when the terminal is resized.
|
|
14
|
+
- Repaint the multiplexer viewport on resize so stale rows are not left behind (#1137).
|
|
15
|
+
- Fixed Alt+Enter queue key parsing (#1145).
|
|
16
|
+
- Fixed Windows prompt newline shortcut discoverability (#1134).
|
|
17
|
+
|
|
18
|
+
## [0.7.3] - 2026-06-25
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- Added bottom-pinned TUI layout support so short initial screens can pad above composer/status components and keep the prompt anchored to the terminal viewport bottom (#1120).
|
|
23
|
+
|
|
5
24
|
## [0.6.0] - 2026-06-18
|
|
6
25
|
|
|
7
26
|
### Fixed
|
package/dist/types/tui.d.ts
CHANGED
|
@@ -156,6 +156,7 @@ export declare class TUI extends Container {
|
|
|
156
156
|
*/
|
|
157
157
|
setClearOnShrink(enabled: boolean): void;
|
|
158
158
|
setFocus(component: Component | null): void;
|
|
159
|
+
setBottomPinnedComponent(component: Component | null): void;
|
|
159
160
|
/**
|
|
160
161
|
* Show an overlay component with configurable positioning and sizing.
|
|
161
162
|
* Returns a handle to control the overlay's visibility.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@sayknow-cli/tui",
|
|
4
|
-
"version": "0.3.
|
|
4
|
+
"version": "0.3.2",
|
|
5
5
|
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
|
6
6
|
"homepage": "https://github.com/jaybeyond/Sayknow_CLI",
|
|
7
7
|
"author": "jaybeyond",
|
|
@@ -38,8 +38,8 @@
|
|
|
38
38
|
"fmt": "biome format --write ."
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
|
-
"@sayknow-cli/natives": "0.3.
|
|
42
|
-
"@sayknow-cli/utils": "0.3.
|
|
41
|
+
"@sayknow-cli/natives": "0.3.2",
|
|
42
|
+
"@sayknow-cli/utils": "0.3.2",
|
|
43
43
|
"lru-cache": "11.3.6",
|
|
44
44
|
"marked": "^18.0.3"
|
|
45
45
|
},
|
package/src/components/editor.ts
CHANGED
|
@@ -385,6 +385,8 @@ export class Editor implements Component, Focusable {
|
|
|
385
385
|
|
|
386
386
|
#theme: EditorTheme;
|
|
387
387
|
#useTerminalCursor = false;
|
|
388
|
+
// macOS: Hangul arriving within 50ms of a line-delete is an IME ghost char — discard it.
|
|
389
|
+
#lastLineDeleteAt = 0;
|
|
388
390
|
|
|
389
391
|
/** When set, replaces the normal cursor glyph at end-of-text with this ANSI-styled string. */
|
|
390
392
|
cursorOverride: string | undefined;
|
|
@@ -854,7 +856,13 @@ export class Editor implements Component, Focusable {
|
|
|
854
856
|
// When NOT focused, show the placeholder alone with no caret.
|
|
855
857
|
if (showPlaceholder && !this.focused) {
|
|
856
858
|
const hintText = hintStyle(truncateToWidth(this.#placeholder ?? "", lineContentWidth));
|
|
857
|
-
|
|
859
|
+
// Anchor the hardware cursor at the input start (terminal-cursor mode) even
|
|
860
|
+
// while the placeholder shows. Otherwise no cursor marker is emitted, the
|
|
861
|
+
// hardware cursor is left at its stale position from the previous frame,
|
|
862
|
+
// and a composing IME preedit renders there instead of at the prompt.
|
|
863
|
+
// The marker is zero-width and stripped before output, so the placeholder is unchanged.
|
|
864
|
+
const anchorCursor = emitCursorMarker && this.#useTerminalCursor && visibleIndex === 0;
|
|
865
|
+
displayText = anchorCursor ? marker + hintText : hintText;
|
|
858
866
|
displayWidth = Math.min(visibleWidth(this.#placeholder ?? ""), lineContentWidth);
|
|
859
867
|
hasCursor = false;
|
|
860
868
|
}
|
|
@@ -1388,6 +1396,21 @@ export class Editor implements Component, Focusable {
|
|
|
1388
1396
|
else {
|
|
1389
1397
|
const printableText = extractPrintableText(data);
|
|
1390
1398
|
if (printableText) {
|
|
1399
|
+
if (
|
|
1400
|
+
process.platform === "darwin" &&
|
|
1401
|
+
printableText.length === 1 &&
|
|
1402
|
+
Date.now() - this.#lastLineDeleteAt < 50
|
|
1403
|
+
) {
|
|
1404
|
+
const code = printableText.charCodeAt(0);
|
|
1405
|
+
const isHangul =
|
|
1406
|
+
(code >= 0xac00 && code <= 0xd7a3) ||
|
|
1407
|
+
(code >= 0x1100 && code <= 0x11ff) ||
|
|
1408
|
+
(code >= 0x3130 && code <= 0x318f);
|
|
1409
|
+
if (isHangul) {
|
|
1410
|
+
this.#lastLineDeleteAt = 0;
|
|
1411
|
+
return;
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1391
1414
|
this.#insertCharacter(printableText);
|
|
1392
1415
|
}
|
|
1393
1416
|
}
|
|
@@ -2213,6 +2236,7 @@ export class Editor implements Component, Focusable {
|
|
|
2213
2236
|
#deleteToStartOfLine(): void {
|
|
2214
2237
|
this.#historyIndex = -1; // Exit history browsing mode
|
|
2215
2238
|
this.#recordUndoState();
|
|
2239
|
+
if (process.platform === "darwin") this.#lastLineDeleteAt = Date.now();
|
|
2216
2240
|
|
|
2217
2241
|
const currentLine = this.#state.lines[this.#state.cursorLine] || "";
|
|
2218
2242
|
let deletedText = "";
|
package/src/tui.ts
CHANGED
|
@@ -313,6 +313,10 @@ export class TUI extends Container {
|
|
|
313
313
|
#sixelProbeTimeout?: NodeJS.Timeout;
|
|
314
314
|
#sixelProbeUnsubscribe?: () => void;
|
|
315
315
|
#showHardwareCursor = $flag("PI_HARDWARE_CURSOR");
|
|
316
|
+
// macOS: steady-block cursor anchors CJK IME overlays; disable with SKC_TUI_IME_CURSOR=0.
|
|
317
|
+
readonly #useImeBlockCursor = $flag("SKC_TUI_IME_CURSOR", process.platform === "darwin");
|
|
318
|
+
// showHardwareCursor=false but cursor is shown for IME anchoring (macOS).
|
|
319
|
+
#imeCursorActive = false;
|
|
316
320
|
#clearOnShrink = $flag("PI_CLEAR_ON_SHRINK"); // Clear empty rows when content shrinks (default: off)
|
|
317
321
|
// Opt-in: reuse the previous normalized off-screen prefix and only normalize/diff the
|
|
318
322
|
// visible window, bounding per-frame work on huge transcripts. Output stays byte-identical.
|
|
@@ -321,6 +325,7 @@ export class TUI extends Container {
|
|
|
321
325
|
#fullRedrawCount = 0;
|
|
322
326
|
#stopped = false;
|
|
323
327
|
#terminalUnavailable = false;
|
|
328
|
+
#bottomPinnedComponent: Component | null = null;
|
|
324
329
|
|
|
325
330
|
// Overlay stack for modal components rendered on top of base content
|
|
326
331
|
overlayStack: {
|
|
@@ -336,6 +341,7 @@ export class TUI extends Container {
|
|
|
336
341
|
if (showHardwareCursor !== undefined) {
|
|
337
342
|
this.#showHardwareCursor = showHardwareCursor;
|
|
338
343
|
}
|
|
344
|
+
this.#imeCursorActive = !this.#showHardwareCursor && this.#useImeBlockCursor;
|
|
339
345
|
}
|
|
340
346
|
|
|
341
347
|
get fullRedraws(): number {
|
|
@@ -349,6 +355,7 @@ export class TUI extends Container {
|
|
|
349
355
|
setShowHardwareCursor(enabled: boolean): void {
|
|
350
356
|
if (this.#showHardwareCursor === enabled) return;
|
|
351
357
|
this.#showHardwareCursor = enabled;
|
|
358
|
+
this.#imeCursorActive = !enabled && this.#useImeBlockCursor;
|
|
352
359
|
if (!enabled) {
|
|
353
360
|
this.#hideCursor();
|
|
354
361
|
}
|
|
@@ -382,6 +389,11 @@ export class TUI extends Container {
|
|
|
382
389
|
}
|
|
383
390
|
}
|
|
384
391
|
|
|
392
|
+
setBottomPinnedComponent(component: Component | null): void {
|
|
393
|
+
this.#bottomPinnedComponent = component;
|
|
394
|
+
this.requestRender();
|
|
395
|
+
}
|
|
396
|
+
|
|
385
397
|
/**
|
|
386
398
|
* Show an overlay component with configurable positioning and sizing.
|
|
387
399
|
* Returns a handle to control the overlay's visibility.
|
|
@@ -478,7 +490,10 @@ export class TUI extends Container {
|
|
|
478
490
|
this.#terminalUnavailable = false;
|
|
479
491
|
this.terminal.start(
|
|
480
492
|
data => this.#handleInput(data),
|
|
481
|
-
() =>
|
|
493
|
+
() => {
|
|
494
|
+
this.invalidate();
|
|
495
|
+
this.requestRender(!(isMultiplexerSession() && !useLegacyMultiplexerFullRender()), "resize");
|
|
496
|
+
},
|
|
482
497
|
);
|
|
483
498
|
this.#hideCursor();
|
|
484
499
|
this.#querySixelSupport();
|
|
@@ -698,6 +713,9 @@ export class TUI extends Container {
|
|
|
698
713
|
this.#writeTerminal("\r\n");
|
|
699
714
|
}
|
|
700
715
|
|
|
716
|
+
if (this.#useImeBlockCursor) {
|
|
717
|
+
this.#writeTerminal("\x1b[0 q");
|
|
718
|
+
}
|
|
701
719
|
this.#showCursor();
|
|
702
720
|
try {
|
|
703
721
|
this.terminal.stop();
|
|
@@ -1269,6 +1287,31 @@ export class TUI extends Container {
|
|
|
1269
1287
|
return lines;
|
|
1270
1288
|
}
|
|
1271
1289
|
|
|
1290
|
+
#padBeforeBottomPinnedComponent(lines: string[], height: number): string[] {
|
|
1291
|
+
const component = this.#bottomPinnedComponent;
|
|
1292
|
+
if (component === null || lines.length >= height) return lines;
|
|
1293
|
+
|
|
1294
|
+
let pinnedStart = -1;
|
|
1295
|
+
for (let i = this.children.length - 1; i >= 0; i--) {
|
|
1296
|
+
if (this.children[i] === component) {
|
|
1297
|
+
pinnedStart = i;
|
|
1298
|
+
break;
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
if (pinnedStart < 0) return lines;
|
|
1302
|
+
|
|
1303
|
+
let pinnedLineCount = 0;
|
|
1304
|
+
for (let i = pinnedStart; i < this.children.length; i++) {
|
|
1305
|
+
pinnedLineCount += this.children[i].render(this.terminal.columns).length;
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
const blankRows = height - lines.length;
|
|
1309
|
+
const insertAt = Math.max(0, lines.length - pinnedLineCount);
|
|
1310
|
+
const padded = [...lines];
|
|
1311
|
+
padded.splice(insertAt, 0, ...Array.from({ length: blankRows }, () => ""));
|
|
1312
|
+
return padded;
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1272
1315
|
#doRender(): void {
|
|
1273
1316
|
if (this.#stopped || !this.terminalAvailable) return;
|
|
1274
1317
|
const width = this.terminal.columns;
|
|
@@ -1287,6 +1330,10 @@ export class TUI extends Container {
|
|
|
1287
1330
|
let newLines = this.render(width);
|
|
1288
1331
|
if (renderMetrics.enabled) renderMetrics.recordHelper("renderTree", renderMetrics.now() - renderTreeStart);
|
|
1289
1332
|
|
|
1333
|
+
if (this.#bottomPinnedComponent !== null && height > 0) {
|
|
1334
|
+
newLines = this.#padBeforeBottomPinnedComponent(newLines, height);
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1290
1337
|
// Composite overlays into the rendered lines (before differential compare)
|
|
1291
1338
|
if (this.overlayStack.length > 0) {
|
|
1292
1339
|
newLines = this.#compositeOverlays(newLines, width, height);
|
|
@@ -1727,8 +1774,10 @@ export class TUI extends Container {
|
|
|
1727
1774
|
totalLines: number,
|
|
1728
1775
|
fromRow: number,
|
|
1729
1776
|
): { seq: string; toRow: number } {
|
|
1730
|
-
|
|
1731
|
-
|
|
1777
|
+
if (!cursorPos || totalLines <= 0) {
|
|
1778
|
+
const hide = this.#useImeBlockCursor ? "\x1b[0 q\x1b[?25l" : "\x1b[?25l";
|
|
1779
|
+
return { seq: hide, toRow: fromRow };
|
|
1780
|
+
}
|
|
1732
1781
|
|
|
1733
1782
|
// Clamp cursor position to valid range
|
|
1734
1783
|
const targetRow = Math.max(0, Math.min(cursorPos.row, totalLines - 1));
|
|
@@ -1744,7 +1793,11 @@ export class TUI extends Container {
|
|
|
1744
1793
|
}
|
|
1745
1794
|
// Move to absolute column (1-indexed)
|
|
1746
1795
|
seq += `\x1b[${targetCol + 1}G`;
|
|
1747
|
-
|
|
1796
|
+
if (this.#showHardwareCursor || this.#imeCursorActive) {
|
|
1797
|
+
seq += this.#useImeBlockCursor ? "\x1b[2 q\x1b[?25h" : "\x1b[?25h";
|
|
1798
|
+
} else {
|
|
1799
|
+
seq += "\x1b[?25l";
|
|
1800
|
+
}
|
|
1748
1801
|
|
|
1749
1802
|
return { seq, toRow: targetRow };
|
|
1750
1803
|
}
|
|
@@ -1761,6 +1814,7 @@ export class TUI extends Container {
|
|
|
1761
1814
|
}
|
|
1762
1815
|
const { seq, toRow } = this.#cursorControlSequence(cursorPos, totalLines, this.#hardwareCursorRow);
|
|
1763
1816
|
this.#hardwareCursorRow = toRow;
|
|
1764
|
-
|
|
1817
|
+
// No \x1b[?2026h/l wrapper: synchronized output flushes terminal state and discards macOS IME composition.
|
|
1818
|
+
this.#writeTerminal(seq);
|
|
1765
1819
|
}
|
|
1766
1820
|
}
|