@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 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
@@ -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.1",
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.1",
42
- "@sayknow-cli/utils": "0.3.1",
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
  },
@@ -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
- displayText = hintText;
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
- () => this.requestRender(),
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
- // No IME target or no content — hide cursor regardless of preference
1731
- if (!cursorPos || totalLines <= 0) return { seq: "\x1b[?25l", toRow: fromRow };
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
- seq += this.#showHardwareCursor ? "\x1b[?25h" : "\x1b[?25l";
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
- this.#writeTerminal(`\x1b[?2026h${seq}\x1b[?2026l`);
1817
+ // No \x1b[?2026h/l wrapper: synchronized output flushes terminal state and discards macOS IME composition.
1818
+ this.#writeTerminal(seq);
1765
1819
  }
1766
1820
  }