@robota-sdk/agent-ui-terminal 3.0.0-beta.93 → 3.0.0-beta.94

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@robota-sdk/agent-ui-terminal",
3
- "version": "3.0.0-beta.93",
3
+ "version": "3.0.0-beta.94",
4
4
  "description": "Terminal UI package (React + Ink) for the Robota SDK",
5
5
  "type": "module",
6
6
  "main": "dist/node/index.js",
@@ -38,15 +38,15 @@
38
38
  "marked-terminal": "^7.3.0",
39
39
  "react": "19.2.8",
40
40
  "string-width": "^8.2.2",
41
- "@robota-sdk/agent-core": "3.0.0-beta.93",
42
- "@robota-sdk/agent-framework": "3.0.0-beta.93",
43
- "@robota-sdk/agent-interface-analytics": "3.0.0-beta.93",
44
- "@robota-sdk/agent-interface-command": "3.0.0-beta.93",
45
- "@robota-sdk/agent-interface-execution": "3.0.0-beta.93",
46
- "@robota-sdk/agent-interface-session": "3.0.0-beta.93",
47
- "@robota-sdk/agent-interface-transport": "3.0.0-beta.93",
48
- "@robota-sdk/agent-interface-tui": "3.0.0-beta.93",
49
- "@robota-sdk/agent-transport": "3.0.0-beta.93"
41
+ "@robota-sdk/agent-core": "3.0.0-beta.94",
42
+ "@robota-sdk/agent-interface-analytics": "3.0.0-beta.94",
43
+ "@robota-sdk/agent-framework": "3.0.0-beta.94",
44
+ "@robota-sdk/agent-interface-session": "3.0.0-beta.94",
45
+ "@robota-sdk/agent-interface-execution": "3.0.0-beta.94",
46
+ "@robota-sdk/agent-interface-transport": "3.0.0-beta.94",
47
+ "@robota-sdk/agent-interface-tui": "3.0.0-beta.94",
48
+ "@robota-sdk/agent-interface-command": "3.0.0-beta.94",
49
+ "@robota-sdk/agent-transport": "3.0.0-beta.94"
50
50
  },
51
51
  "devDependencies": {
52
52
  "@homebridge/node-pty-prebuilt-multiarch": "^0.14.1",
@@ -57,7 +57,7 @@
57
57
  "tsx": "^4.23.1",
58
58
  "typescript": "^6.0.3",
59
59
  "vitest": "^3.2.6",
60
- "@robota-sdk/agent-command": "3.0.0-beta.93"
60
+ "@robota-sdk/agent-command": "3.0.0-beta.94"
61
61
  },
62
62
  "license": "AGPL-3.0-only OR LicenseRef-Commercial",
63
63
  "publishConfig": {
@@ -0,0 +1,17 @@
1
+ {
2
+ "greeting": "안녕하세요",
3
+ "greetingWithoutLastSyllable": "안녕하세",
4
+ "greetingWithoutMiddle": "안세요",
5
+ "greetingFirstTwoSyllables": "안녕",
6
+ "greetingFirstThreeSyllables": "안녕하",
7
+ "greetingMiddleTwoSyllables": "녕하",
8
+ "greetingFirstSyllable": "안",
9
+ "greetingThirdSyllable": "하",
10
+ "greetingLastSyllable": "요",
11
+ "hangulWord": "한글",
12
+ "hangulWordPlusTe": "한글테",
13
+ "hangulWordPlusTest": "한글테스트",
14
+ "hanSyllable": "한",
15
+ "longResponseStreaming": "긴 한국어 응답과 emoji 🎉 를 스트리밍합니다",
16
+ "longResponseDisplay": "긴 한국어 응답과 emoji 🎉 를 표시합니다"
17
+ }
@@ -4,6 +4,7 @@ import { describe, expect, it, vi } from 'vitest';
4
4
 
5
5
  import CjkTextInput from '../CjkTextInput.js';
6
6
  import { IME_SUBMIT_DEFER_MS } from '../flows/defer-submit.js';
7
+ import korean from './__fixtures__/korean-text.json';
7
8
 
8
9
  /**
9
10
  * CLI-061 — integration proof that the CjkTextInput defer-submit includes a trailing IME character whose stdin
@@ -29,23 +30,23 @@ function Harness({ onSubmit }: { onSubmit: (value: string) => void }): React.Rea
29
30
  }
30
31
 
31
32
  describe('CLI-061 — CjkTextInput defer-submit (integration)', () => {
32
- it('includes a trailing syllable that arrives after Enter (안녕하세 + Enter + 요 → 안녕하세요)', async () => {
33
+ it('includes a trailing syllable that arrives after Enter (greeting without its last syllable + Enter + last syllable → full greeting)', async () => {
33
34
  const onSubmit = vi.fn();
34
35
  const { stdin } = render(<Harness onSubmit={onSubmit} />);
35
36
  await tick();
36
37
 
37
- stdin.write('안녕하세'); // composed so far
38
+ stdin.write(korean.greetingWithoutLastSyllable); // composed so far
38
39
  await tick();
39
40
  stdin.write('\r'); // Enter — schedules the deferred submit, does NOT submit synchronously
40
41
  await tick();
41
42
  expect(onSubmit).not.toHaveBeenCalled(); // deferred, not immediate
42
43
 
43
- stdin.write('요'); // the final syllable's stdin event lands after Enter; the pipeline is still live
44
+ stdin.write(korean.greetingLastSyllable); // the final syllable's stdin event lands after Enter; the pipeline is still live
44
45
  await tick();
45
46
 
46
47
  await waitDefer(); // the defer window elapses → the deferred submit fires, re-reading the LATEST value
47
48
  expect(onSubmit).toHaveBeenCalledTimes(1);
48
- expect(onSubmit).toHaveBeenCalledWith('안녕하세요');
49
+ expect(onSubmit).toHaveBeenCalledWith(korean.greeting);
49
50
  }, 10_000);
50
51
 
51
52
  it('a second Enter within the window does not double-submit', async () => {
@@ -16,6 +16,7 @@ import React from 'react';
16
16
  import { afterAll, beforeAll, describe, expect, it } from 'vitest';
17
17
 
18
18
  import CjkTextInput from '../CjkTextInput.js';
19
+ import korean from './__fixtures__/korean-text.json';
19
20
 
20
21
  const noop = (): void => {};
21
22
  const tick = (): Promise<void> => new Promise((r) => setTimeout(r, 30));
@@ -43,9 +44,9 @@ describe('CLI-062 — fallback rendering is byte-identical to pre-change output'
43
44
  });
44
45
 
45
46
  it('CJK value with drawn end-of-line cursor', async () => {
46
- const { lastFrame, unmount } = render(<CjkTextInput value="안녕하세요" onChange={noop} />);
47
+ const { lastFrame, unmount } = render(<CjkTextInput value={korean.greeting} onChange={noop} />);
47
48
  await tick();
48
- expect(lastFrame()).toBe('안녕하세요\u001b[7m \u001b[27m');
49
+ expect(lastFrame()).toBe(`${korean.greeting}\u001b[7m \u001b[27m`);
49
50
  unmount();
50
51
  });
51
52
 
@@ -69,10 +70,20 @@ describe('CLI-062 — fallback rendering is byte-identical to pre-change output'
69
70
 
70
71
  it('long value wraps identically (the <Box ref> wrapper must not change layout)', async () => {
71
72
  const { lastFrame, unmount } = render(
72
- <CjkTextInput value={'a'.repeat(150) + '안녕'} onChange={noop} availableWidth={58} />,
73
+ <CjkTextInput
74
+ value={'a'.repeat(150) + korean.greetingFirstTwoSyllables}
75
+ onChange={noop}
76
+ availableWidth={58}
77
+ />,
73
78
  );
74
79
  await tick();
75
- expect(lastFrame()).toBe('a'.repeat(100) + '\n' + 'a'.repeat(50) + '안녕\u001b[7m \u001b[27m');
80
+ expect(lastFrame()).toBe(
81
+ 'a'.repeat(100) +
82
+ '\n' +
83
+ 'a'.repeat(50) +
84
+ korean.greetingFirstTwoSyllables +
85
+ '\u001b[7m \u001b[27m',
86
+ );
76
87
  unmount();
77
88
  });
78
89
  });
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest';
2
+ import korean from './__fixtures__/korean-text.json';
2
3
  import {
3
4
  applyCjkTextInput,
4
5
  applyCjkTextPaste,
@@ -53,10 +54,10 @@ describe('cjk text input flow', () => {
53
54
  it('Given Ink usePaste single-line text When applied Then text is inserted at cursor', () => {
54
55
  const state = { ...createCjkTextInputFlowState('ab'), cursor: 1 };
55
56
 
56
- const result = applyCjkTextPaste(state, '한글', { canPaste: true });
57
+ const result = applyCjkTextPaste(state, korean.hangulWord, { canPaste: true });
57
58
 
58
- expect(result.state).toMatchObject({ value: 'a한글b', cursor: 3 });
59
- expect(result.effect).toEqual({ type: 'change', value: 'a한글b' });
59
+ expect(result.state).toMatchObject({ value: `a${korean.hangulWord}b`, cursor: 3 });
60
+ expect(result.effect).toEqual({ type: 'change', value: `a${korean.hangulWord}b` });
60
61
  });
61
62
 
62
63
  it('Given Ink usePaste multiline text When applied Then normalized paste effect is emitted', () => {
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import { describe, it, expect } from 'vitest';
9
+ import korean from './__fixtures__/korean-text.json';
9
10
  import {
10
11
  filterPrintable,
11
12
  insertAtCursor,
@@ -31,7 +32,7 @@ describe('filterPrintable', () => {
31
32
  });
32
33
 
33
34
  it('passes through Korean characters', () => {
34
- expect(filterPrintable('안녕하세요')).toBe('안녕하세요');
35
+ expect(filterPrintable(korean.greeting)).toBe(korean.greeting);
35
36
  });
36
37
 
37
38
  it('passes through emoji', () => {
@@ -99,8 +100,12 @@ describe('insertAtCursor', () => {
99
100
  });
100
101
 
101
102
  it('handles Korean character insertion', () => {
102
- const result = insertAtCursor('안세요', 1, '녕하');
103
- expect(result.value).toBe('안녕하세요');
103
+ const result = insertAtCursor(
104
+ korean.greetingWithoutMiddle,
105
+ 1,
106
+ korean.greetingMiddleTwoSyllables,
107
+ );
108
+ expect(result.value).toBe(korean.greeting);
104
109
  expect(result.cursor).toBe(3);
105
110
  });
106
111
 
@@ -126,24 +131,24 @@ describe('displayOffset', () => {
126
131
  });
127
132
 
128
133
  it('cursor before CJK char is at end of previous content', () => {
129
- // "abcd한" width 5: cursor before "한" is at col 4 on line 0
130
- // gap happens when "한" renders, not at cursor position
131
- expect(displayOffset([...'abcd한'], 4, 5)).toBe(4);
132
- // cursor after "한": 4(gap included) + 2 = 7 → but via offset: gap is at render
133
- expect(displayOffset([...'abcd한'], 5, 5)).toBe(7);
134
+ // "abcd" + one wide syllable, width 5: cursor before the syllable is at col 4 on line 0
135
+ // gap happens when the syllable renders, not at cursor position
136
+ expect(displayOffset([...`abcd${korean.hanSyllable}`], 4, 5)).toBe(4);
137
+ // cursor after the syllable: 4(gap included) + 2 = 7 → but via offset: gap is at render
138
+ expect(displayOffset([...`abcd${korean.hanSyllable}`], 5, 5)).toBe(7);
134
139
  });
135
140
 
136
141
  it('handles pure CJK', () => {
137
- // "한글" width 5: "한"=2, "글"=2 → total 4, fits on one line
138
- expect(displayOffset([...'한글'], 2, 5)).toBe(4);
142
+ // two wide syllables, width 5: 2 + 2 → total 4, fits on one line
143
+ expect(displayOffset([...korean.hangulWord], 2, 5)).toBe(4);
139
144
  });
140
145
 
141
146
  it('handles CJK wrapping', () => {
142
- // "한글테" width 5: cursor before "테" is after "글" (offset 4)
143
- // "테" doesn't fit at col 4, but cursor is BEFORE the wrap
144
- expect(displayOffset([...'한글테'], 2, 5)).toBe(4);
145
- // cursor after "테": gap(1) + 2 = offset 7
146
- expect(displayOffset([...'한글테'], 3, 5)).toBe(7);
147
+ // three wide syllables, width 5: cursor before the third is after the second (offset 4)
148
+ // the third syllable doesn't fit at col 4, but cursor is BEFORE the wrap
149
+ expect(displayOffset([...korean.hangulWordPlusTe], 2, 5)).toBe(4);
150
+ // cursor after the third syllable: gap(1) + 2 = offset 7
151
+ expect(displayOffset([...korean.hangulWordPlusTe], 3, 5)).toBe(7);
147
152
  });
148
153
  });
149
154
 
@@ -171,7 +176,7 @@ describe('charIndexAtDisplayOffset', () => {
171
176
  });
172
177
 
173
178
  it('round-trips with displayOffset for CJK', () => {
174
- const text = '한글테스트';
179
+ const text = korean.hangulWordPlusTest;
175
180
  const chars = [...text];
176
181
  const width = 5;
177
182
  for (let i = 0; i <= chars.length; i++) {
@@ -15,6 +15,7 @@ import { describe, it, expect } from 'vitest';
15
15
  import MessageList from '../MessageList.js';
16
16
  import { STATUS_SYMBOL, statusGlyphColor } from '../status-glyph.js';
17
17
  import { DARK_THEME } from '../theme/built-in-themes.js';
18
+ import korean from './__fixtures__/korean-text.json';
18
19
  import type { IHistoryEntry } from '@robota-sdk/agent-core';
19
20
  import {
20
21
  createUserMessage,
@@ -154,7 +155,7 @@ describe('MessageList rendering', () => {
154
155
  });
155
156
 
156
157
  it('assistant message preserves CJK and emoji content', () => {
157
- const content = '긴 한국어 응답과 emoji 🎉 를 표시합니다';
158
+ const content = korean.longResponseDisplay;
158
159
  const history: IHistoryEntry[] = [messageToHistoryEntry(createAssistantMessage(content))];
159
160
  const { lastFrame } = render(<MessageList history={history} />);
160
161
 
@@ -27,6 +27,7 @@ import { tmpdir } from 'node:os';
27
27
  import { join, resolve } from 'node:path';
28
28
 
29
29
  import { afterEach, beforeEach, describe, expect, it } from 'vitest';
30
+ import korean from '../__fixtures__/korean-text.json';
30
31
 
31
32
  import { fixtureProductEnvironment } from './isolated-home.js';
32
33
 
@@ -38,7 +39,7 @@ const GIT_BIN =
38
39
  .map((directory) => join(directory, 'git'))
39
40
  .find((candidate) => existsSync(candidate)) ?? '/usr/bin/git';
40
41
  const COLS = 80;
41
- const COMPOSITION = '안녕';
42
+ const COMPOSITION = korean.greetingFirstTwoSyllables;
42
43
  const COMPOSITION_WIDTH = 4;
43
44
 
44
45
  function resolveTmux(): string | undefined {
@@ -23,6 +23,7 @@ import { tmpdir } from 'node:os';
23
23
  import { join } from 'node:path';
24
24
 
25
25
  import { afterEach, beforeEach, describe, expect, it } from 'vitest';
26
+ import korean from '../__fixtures__/korean-text.json';
26
27
 
27
28
  import { spawnTui, writeTuiProviderSettings } from './pty-driver.js';
28
29
  import {
@@ -39,8 +40,8 @@ import type { ICursorShowEvent } from '../helpers/vt-cursor-interpreter.js';
39
40
 
40
41
  const COLS = 80;
41
42
  const PROMPT_PLACEHOLDER = /Type a message or \/help/;
42
- const COMPOSITION = '안녕';
43
- /** `안녕` occupies 4 display columns; the prompt adds `> `. */
43
+ const COMPOSITION = korean.greetingFirstTwoSyllables;
44
+ /** The composition occupies 4 display columns; the prompt adds `> `. */
44
45
  const COMPOSITION_WIDTH = 4;
45
46
  /** Let ink's throttled (≤30fps) frame writes flush after the last keystroke. */
46
47
  const flush = (): Promise<void> => new Promise((r) => setTimeout(r, 400));
@@ -48,7 +49,7 @@ const flush = (): Promise<void> => new Promise((r) => setTimeout(r, 400));
48
49
  interface IProbeResult {
49
50
  /** Cursor shows emitted after boot — the composition window the OS IME would act on. */
50
51
  compositionShows: ICursorShowEvent[];
51
- /** Screen row holding `> 안녕`, or -1 when the composition never rendered. */
52
+ /** Screen row holding `> ` followed by the composition, or -1 when the composition never rendered. */
52
53
  inputRow: number;
53
54
  /** Column of the `> ` prompt on the input row, or -1. */
54
55
  promptCol: number;
@@ -23,6 +23,7 @@ import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest
23
23
  import { interpretVtStream } from './helpers/vt-cursor-interpreter.js';
24
24
  import CjkTextInput from '../CjkTextInput.js';
25
25
  import { TerminalCapabilitiesProvider } from '../terminal-capabilities-context.js';
26
+ import korean from './__fixtures__/korean-text.json';
26
27
 
27
28
  const COLS = 60;
28
29
  const ROWS = 24;
@@ -143,7 +144,7 @@ describe('CLI-062 — real cursor positioning (interactive ink render)', () => {
143
144
  const { stdout, stdin, unmount } = renderScenario();
144
145
  await settle();
145
146
 
146
- stdin.send('안녕');
147
+ stdin.send(korean.greetingFirstTwoSyllables);
147
148
  await settle();
148
149
 
149
150
  const afterTwo = interpretVtStream(stdout.stream(), ROWS, COLS);
@@ -153,10 +154,10 @@ describe('CLI-062 — real cursor positioning (interactive ink render)', () => {
153
154
  for (const event of showsAfterTwo) {
154
155
  expect(event.row).toBe(INPUT_ROW);
155
156
  }
156
- // Composition column: '> ' (2 cols) + 안녕 (2 wide chars = 4 cols).
157
+ // Composition column: '> ' (2 cols) + two syllables (2 wide chars = 4 cols).
157
158
  expect(showsAfterTwo.at(-1)).toMatchObject({ row: INPUT_ROW, col: 6 });
158
159
 
159
- stdin.send('하'); // one more wide char → +2 columns
160
+ stdin.send(korean.greetingThirdSyllable); // one more wide char → +2 columns
160
161
  await settle();
161
162
 
162
163
  const afterThree = interpretVtStream(stdout.stream(), ROWS, COLS);
@@ -189,7 +190,7 @@ describe('CLI-062 — real cursor positioning (interactive ink render)', () => {
189
190
  for (const bannerLines of [1, 4]) {
190
191
  const { stdout, stdin, unmount } = renderScenario(true, bannerLines);
191
192
  await settle();
192
- stdin.send('안녕');
193
+ stdin.send(korean.greetingFirstTwoSyllables);
193
194
  await settle();
194
195
 
195
196
  const vt = interpretVtStream(stdout.stream(), ROWS, COLS);
@@ -234,7 +235,7 @@ describe('CLI-062 — real cursor positioning (interactive ink render)', () => {
234
235
  },
235
236
  );
236
237
  await settle();
237
- stdin.send('안');
238
+ stdin.send(korean.greetingFirstSyllable);
238
239
  await settle();
239
240
  expect(interpretVtStream(stdout.stream(), ROWS, COLS).showEvents.length).toBeGreaterThan(0);
240
241
 
@@ -3,6 +3,7 @@ import { describe, it, expect } from 'vitest';
3
3
  import { render } from 'ink-testing-library';
4
4
  import StreamingIndicator from '../StreamingIndicator.js';
5
5
  import { ScreenReaderProvider } from '../screen-reader-context.js';
6
+ import korean from './__fixtures__/korean-text.json';
6
7
 
7
8
  describe('StreamingIndicator', () => {
8
9
  it('renders empty when no tools and no text', () => {
@@ -52,7 +53,7 @@ describe('StreamingIndicator', () => {
52
53
  });
53
54
 
54
55
  it('preserves CJK and emoji text in streaming output', () => {
55
- const text = '긴 한국어 응답과 emoji 🎉 를 스트리밍합니다';
56
+ const text = korean.longResponseStreaming;
56
57
  const { lastFrame } = render(<StreamingIndicator text={text} activeTools={[]} />);
57
58
 
58
59
  expect(lastFrame()).toContain(text);
@@ -1,6 +1,7 @@
1
1
  import { EventEmitter } from 'node:events';
2
2
 
3
3
  import { describe, expect, it, vi } from 'vitest';
4
+ import korean from '../../__tests__/__fixtures__/korean-text.json';
4
5
 
5
6
  import { filterFocusSequences, FocusReportingStdin } from '../focus-input-filter.js';
6
7
  import { createFocusReportingWriter } from '../../terminal-focus-reporting.js';
@@ -33,8 +34,8 @@ describe('focus input filter (SCREEN-1992 TC-06)', () => {
33
34
  expect(filterFocusSequences(Buffer.from('A'), true).forwarded.toString()).toBe('A');
34
35
  const paste = '\x1b[200~pasted \x1b[I text\x1b[201~';
35
36
  expect(filterFocusSequences(Buffer.from(paste), false).forwarded.toString()).toBe(paste);
36
- const utf8 = Buffer.from('한글\x1b[O😀');
37
- expect(filterFocusSequences(utf8, true).forwarded.toString()).toBe('한글😀');
37
+ const utf8 = Buffer.from(`${korean.hangulWord}\x1b[O😀`);
38
+ expect(filterFocusSequences(utf8, true).forwarded.toString()).toBe(`${korean.hangulWord}😀`);
38
39
  });
39
40
 
40
41
  it('proxies stdin: reports focus and keystrokes, forwards isTTY/setRawMode/ref/unref', async () => {
@@ -5,6 +5,7 @@ import {
5
5
  createDeferSubmitState,
6
6
  scheduleDeferredSubmit,
7
7
  } from '../defer-submit.js';
8
+ import korean from '../../__tests__/__fixtures__/korean-text.json';
8
9
 
9
10
  /**
10
11
  * CLI-061 — the deferred-submit orchestration. The load-bearing property: at fire time the submit reads
@@ -30,16 +31,16 @@ describe('scheduleDeferredSubmit (CLI-061)', () => {
30
31
  const sched = manualScheduler();
31
32
 
32
33
  // Value at Enter/schedule time is the pre-trailing-char string...
33
- let live = '안녕하세';
34
+ let live: string = korean.greetingWithoutLastSyllable;
34
35
  scheduleDeferredSubmit(state, () => live, submit, 50, sched.schedule);
35
36
  expect(submit).not.toHaveBeenCalled(); // deferred, not immediate
36
37
 
37
38
  // ...the trailing syllable lands during the window...
38
- live = '안녕하세요';
39
+ live = korean.greeting;
39
40
  sched.fire();
40
41
 
41
42
  expect(submit).toHaveBeenCalledTimes(1);
42
- expect(submit).toHaveBeenCalledWith('안녕하세요'); // FULL value — fails on the pre-fix immediate submit
43
+ expect(submit).toHaveBeenCalledWith(korean.greeting); // FULL value — fails on the pre-fix immediate submit
43
44
  });
44
45
 
45
46
  it('ignores a second submit while one is in flight (no double-submit)', () => {
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import { describe, expect, it } from 'vitest';
12
+ import korean from '../../__tests__/__fixtures__/korean-text.json';
12
13
 
13
14
  import { computeCursorCell, shouldPositionRealCursor } from '../real-cursor-flow.js';
14
15
 
@@ -23,8 +24,15 @@ describe('computeCursorCell', () => {
23
24
  expect(computeCursorCell({ ...origin, value: 'hello', cursor: 0 })).toEqual({ x: 3, y: 2 });
24
25
  });
25
26
 
26
- it('CJK wide chars count 2 columns each (안녕 → 4 columns)', () => {
27
- expect(computeCursorCell({ ...origin, value: '안녕', cursor: 2, availableWidth: 20 })).toEqual({
27
+ it('CJK wide chars count 2 columns each (two syllables → 4 columns)', () => {
28
+ expect(
29
+ computeCursorCell({
30
+ ...origin,
31
+ value: korean.greetingFirstTwoSyllables,
32
+ cursor: 2,
33
+ availableWidth: 20,
34
+ }),
35
+ ).toEqual({
28
36
  x: 7,
29
37
  y: 2,
30
38
  });
@@ -32,12 +40,14 @@ describe('computeCursorCell', () => {
32
40
 
33
41
  it('mid-string cursor inside a CJK value', () => {
34
42
  expect(
35
- computeCursorCell({ ...origin, value: '안녕하세요', cursor: 2, availableWidth: 20 }),
43
+ computeCursorCell({ ...origin, value: korean.greeting, cursor: 2, availableWidth: 20 }),
36
44
  ).toEqual({ x: 7, y: 2 });
37
45
  });
38
46
 
39
47
  it('no wrap width: CJK offset is the plain display-width sum', () => {
40
- expect(computeCursorCell({ ...origin, value: '안녕하', cursor: 3 })).toEqual({ x: 9, y: 2 });
48
+ expect(
49
+ computeCursorCell({ ...origin, value: korean.greetingFirstThreeSyllables, cursor: 3 }),
50
+ ).toEqual({ x: 9, y: 2 });
41
51
  });
42
52
 
43
53
  it('wraps to the next row when the offset passes the width', () => {
@@ -56,7 +66,12 @@ describe('computeCursorCell', () => {
56
66
  // 9 ASCII cols + a wide char that cannot fit in the last column: displayOffset skips the
57
67
  // dead column (9 → 10) then adds the wide char (→ 12) — cursor lands at row+1, col 2.
58
68
  expect(
59
- computeCursorCell({ ...origin, value: 'aaaaaaaaa안', cursor: 10, availableWidth: 10 }),
69
+ computeCursorCell({
70
+ ...origin,
71
+ value: `aaaaaaaaa${korean.greetingFirstSyllable}`,
72
+ cursor: 10,
73
+ availableWidth: 10,
74
+ }),
60
75
  ).toEqual({ x: 5, y: 3 });
61
76
  });
62
77
 
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * IME composition is finalized by the terminal, which writes the final composed character into the pty as its
5
5
  * OWN stdin event that arrives JUST AFTER the confirming `\r`. If we submit synchronously on Enter, the trailing
6
- * syllable has not yet been applied to the input state, so it is dropped (`안녕하세요` → `안녕하세`). Deferring the
6
+ * syllable has not yet been applied to the input state, so it is dropped (a five-syllable Korean greeting loses its last syllable). Deferring the
7
7
  * submit a few milliseconds lets that trailing event land, after which we re-read the LATEST value and submit it.
8
8
  *
9
9
  * This is the same app-layer fix Gemini CLI applied on the same Ink (PR #4987/#7556); no framework change and