sanook-cli 0.5.10 → 0.5.13

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/dist/ui/banner.js CHANGED
@@ -5,6 +5,7 @@ import Gradient from 'ink-gradient';
5
5
  import { homedir } from 'node:os';
6
6
  import { readFileSync } from 'node:fs';
7
7
  import { BRAND } from '../brand.js';
8
+ import { clipToWidth, padEndToWidth } from './text-width.js';
8
9
  // gradient ของ Sanook: เขียว → ส้ม → ฟ้า (สนุก = สดใส)
9
10
  const SANOOK_GRADIENT = ['#22C55E', '#F97316', '#38BDF8'];
10
11
  const BANNER_TITLE = BRAND.bannerWide.toUpperCase();
@@ -24,11 +25,8 @@ const TINY_PANEL_COLUMNS = 44;
24
25
  const MAX_PANEL_COLUMNS = 100;
25
26
  // version จาก package.json (single source of truth) — กัน default drift เหมือน bin.ts
26
27
  const VERSION = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version;
27
- const clip = (text, width) => {
28
- if (width <= 0)
29
- return '';
30
- return text.length > width ? `${text.slice(0, Math.max(0, width - 1))}…` : text;
31
- };
28
+ // display-width aware so the Thai brand line + box border don't drift (Thai marks 0, emoji 2 cells)
29
+ const clip = (text, width) => clipToWidth(text, width);
32
30
  function signalText(signals) {
33
31
  return signals
34
32
  .filter((signal) => signal.label.trim() && signal.value.trim())
@@ -73,7 +71,7 @@ function bannerLines({ account, dir, model, mode, signals, version, }, columns)
73
71
  `◆ ${BRAND_LINE}`,
74
72
  flow,
75
73
  routeLine,
76
- ...SERVICE_ROUTES.map(([num, label, hint]) => `› ${num} ${label.padEnd(7)} ${hint}`),
74
+ ...SERVICE_ROUTES.map(([num, label, hint]) => `› ${num} ${padEndToWidth(label, 7)} ${hint}`),
77
75
  ];
78
76
  }
79
77
  /** welcome banner — Hermes-style responsive wordmark + compact Sanook launchpad. */
@@ -10,16 +10,18 @@ const DEFAULT_PATH = join(homedir(), 'Documents', BRAIN_DEFAULTS.vaultName);
10
10
  export function BrainWizard({ onComplete }) {
11
11
  const [step, setStep] = useState('path');
12
12
  const [path, setPath] = useState(DEFAULT_PATH);
13
- const [ownerName, setOwnerName] = useState(BRAIN_DEFAULTS.ownerName);
14
- const [aiName, setAiName] = useState(BRAIN_DEFAULTS.aiName);
13
+ // raw typed values — '' means "skipped" (so it isn't seeded as a name); the placeholder still shows
14
+ // the default so the user knows what Enter-to-skip yields in the scaffolded vault.
15
+ const [ownerName, setOwnerName] = useState('');
16
+ const [aiName, setAiName] = useState('');
15
17
  return (_jsxs(Box, { flexDirection: "column", gap: 1, marginY: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "\uD83E\uDDE0 \u0E2A\u0E23\u0E49\u0E32\u0E07 Second Brain workspace" }), step === 'path' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "1. \u0E27\u0E32\u0E07\u0E42\u0E04\u0E23\u0E07\u0E2A\u0E23\u0E49\u0E32\u0E07\u0E44\u0E27\u0E49\u0E17\u0E35\u0E48\u0E44\u0E2B\u0E19? (Enter = default)" }), _jsxs(Text, { color: "gray", children: [" ", DEFAULT_PATH] }), _jsx(TextInput, { defaultValue: DEFAULT_PATH, placeholder: DEFAULT_PATH, onSubmit: (v) => {
16
18
  setPath(v.trim() || DEFAULT_PATH);
17
19
  setStep('owner');
18
20
  } })] })), step === 'owner' && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: ["2. \u0E40\u0E23\u0E35\u0E22\u0E01\u0E04\u0E38\u0E13\u0E27\u0E48\u0E32\u0E2D\u0E30\u0E44\u0E23\u0E14\u0E35? (\u0E0A\u0E37\u0E48\u0E2D/\u0E0A\u0E37\u0E48\u0E2D\u0E40\u0E25\u0E48\u0E19 \u2014 Enter \u0E40\u0E1B\u0E25\u0E48\u0E32 = \u0E43\u0E0A\u0E49 \"", BRAIN_DEFAULTS.ownerName, "\")"] }), _jsx(TextInput, { placeholder: BRAIN_DEFAULTS.ownerName, onSubmit: (v) => {
19
- setOwnerName(v.trim() || BRAIN_DEFAULTS.ownerName);
21
+ setOwnerName(v.trim());
20
22
  setStep('ai');
21
23
  } })] })), step === 'ai' && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: ["3. \u0E2D\u0E22\u0E32\u0E01\u0E43\u0E2B\u0E49 AI \u0E40\u0E23\u0E35\u0E22\u0E01\u0E15\u0E31\u0E27\u0E40\u0E2D\u0E07\u0E27\u0E48\u0E32\u0E2D\u0E30\u0E44\u0E23? ", _jsxs(Text, { color: "gray", children: ["(Enter \u0E40\u0E1B\u0E25\u0E48\u0E32 = \"", BRAIN_DEFAULTS.aiName, "\")"] })] }), _jsx(TextInput, { placeholder: BRAIN_DEFAULTS.aiName, onSubmit: (v) => {
22
- setAiName(v.trim() || BRAIN_DEFAULTS.aiName);
24
+ setAiName(v.trim());
23
25
  setStep('autonomy');
24
26
  } })] })), step === 'autonomy' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "4. \u0E43\u0E2B\u0E49 AI \u0E17\u0E33\u0E07\u0E32\u0E19\u0E41\u0E1A\u0E1A\u0E44\u0E2B\u0E19?" }), _jsx(Select, { options: [
25
27
  { label: 'ask-on-risk — ทำเลยถ้าปลอดภัย ถามเฉพาะ destructive (แนะนำ)', value: 'ask-on-risk' },
@@ -1,13 +1,14 @@
1
+ import chalk from 'chalk';
1
2
  import stringWidth from 'string-width';
2
- import { graphemeBoundaries } from './useEditor.js';
3
+ import { clampCursorToGrapheme, graphemeBoundaries } from './useEditor.js';
3
4
  // ────────────────────────────────────────────────────────────────────────────
4
5
  // Stable, Thai-safe rendering of the REPL input line.
6
+ // Regression guards: repl-layout-guard.test.ts + input-view.test.ts (width + gap cursor).
5
7
  //
6
8
  // Two bugs this fixes (เทียบกับ CLI เจ้าอื่นที่ "นิ่ง"):
7
- // 1) cursor split a grapheme cluster — the old code did value.slice(cursor, cursor+1),
8
- // which on Thai cuts a base char away from its combining vowel/tone mark (สระ/วรรณยุกต์
9
- // เป็น zero-width). The orphaned mark then renders on its own cell "อักษรห่างเกินไป".
10
- // Fix: the cursor highlights a WHOLE grapheme cluster (base + all its marks).
9
+ // 1) block cursor on Thai textsplitting the line across multiple Ink <Text> nodes breaks
10
+ // grapheme shaping; inverse video on a middle segment paints over Thai base marks + vowels.
11
+ // Fix: one ANSI string for the whole line + bgCyan gap cell between clusters (never inverse on letters).
11
12
  // 2) the line bounced between 1 and 2 rows while typing — a wrapping <Text> grows the box
12
13
  // vertically the moment content crosses the right edge, shoving the footer down on every
13
14
  // keystroke. Fix: a fixed-width horizontal viewport (readline-style) so the input box is
@@ -26,17 +27,20 @@ export function graphemesOf(value) {
26
27
  out.push(value.slice(bounds[i], bounds[i + 1]));
27
28
  return out;
28
29
  }
29
- /** grapheme-cluster index that a code-unit cursor sits at (0..graphemeCount) */
30
- export function cursorGraphemeIndex(value, cursor) {
30
+ /** insert position between grapheme clusters (0 = before first char, n = after last) */
31
+ export function cursorInsertGraphemeIndex(value, cursor) {
32
+ const clamped = clampCursorToGrapheme(value, cursor);
31
33
  const bounds = graphemeBoundaries(value);
32
- let index = 0;
33
34
  for (let i = 0; i < bounds.length; i += 1) {
34
- if (bounds[i] <= cursor)
35
- index = i;
36
- else
37
- break;
35
+ if (bounds[i] === clamped)
36
+ return i;
38
37
  }
39
- return index;
38
+ return bounds.length - 1;
39
+ }
40
+ /** @deprecated use cursorInsertGraphemeIndex — kept for callers that need cluster index */
41
+ export function cursorGraphemeIndex(value, cursor) {
42
+ const insert = cursorInsertGraphemeIndex(value, cursor);
43
+ return insert >= graphemesOf(value).length ? Math.max(0, insert - 1) : insert;
40
44
  }
41
45
  /** display width of one grapheme, never less than 1 cell (so the cursor always has a cell) */
42
46
  function cellWidth(grapheme) {
@@ -50,27 +54,29 @@ function cellWidth(grapheme) {
50
54
  export function inputViewport(value, cursor, width) {
51
55
  const w = Math.max(4, Math.floor(width));
52
56
  const graphemes = graphemesOf(value);
53
- const ci = cursorGraphemeIndex(value, cursor);
54
- // a trailing sentinel cell so a cursor parked at end-of-line still has somewhere to sit
55
- const units = [...graphemes.map((g) => ({ text: g, width: cellWidth(g) })), { text: ' ', width: 1 }];
56
- const cursorUnit = Math.min(ci, units.length - 1);
57
+ const insertAt = cursorInsertGraphemeIndex(value, cursor);
58
+ const units = [];
59
+ for (let i = 0; i < insertAt; i += 1) {
60
+ units.push({ text: graphemes[i], width: cellWidth(graphemes[i]), isCursor: false });
61
+ }
62
+ units.push({ text: ' ', width: 1, isCursor: true });
63
+ for (let i = insertAt; i < graphemes.length; i += 1) {
64
+ units.push({ text: graphemes[i], width: cellWidth(graphemes[i]), isCursor: false });
65
+ }
66
+ const cursorUnit = insertAt;
57
67
  const totalWidth = units.reduce((sum, u) => sum + u.width, 0);
58
68
  if (totalWidth <= w) {
59
69
  return {
60
70
  lead: false,
61
- before: graphemes.slice(0, cursorUnit).join(''),
62
- at: cursorUnit < graphemes.length ? graphemes[cursorUnit] : ' ',
63
- after: cursorUnit < graphemes.length ? graphemes.slice(cursorUnit + 1).join('') : '',
71
+ before: graphemes.slice(0, insertAt).join(''),
72
+ at: ' ',
73
+ after: graphemes.slice(insertAt).join(''),
64
74
  tail: false,
65
75
  };
66
76
  }
67
- // overflow → slide a window that always contains the cursor unit; reserve 1 cell for each
68
- // truncation marker that will actually be shown.
69
77
  let start = cursorUnit;
70
78
  let end = cursorUnit + 1;
71
79
  let used = units[cursorUnit].width;
72
- // extend right first (so typing at end keeps the tail in view), then backfill left context.
73
- // the marker reservations (start>0 ⇒ ‹, end<len ⇒ ›) are folded into each fit check.
74
80
  while (end < units.length) {
75
81
  const next = units[end].width;
76
82
  if (used + next + (start > 0 ? 1 : 0) + (end + 1 < units.length ? 1 : 0) <= w) {
@@ -91,14 +97,37 @@ export function inputViewport(value, cursor, width) {
91
97
  }
92
98
  const slice = (from, to) => units
93
99
  .slice(from, to)
100
+ .filter((u) => !u.isCursor)
94
101
  .map((u) => u.text)
95
102
  .join('');
96
- const atUnit = units[cursorUnit];
97
103
  return {
98
104
  lead: start > 0,
99
105
  before: slice(start, cursorUnit),
100
- at: cursorUnit === units.length - 1 ? ' ' : atUnit.text,
106
+ at: ' ',
101
107
  after: slice(cursorUnit + 1, end),
102
108
  tail: end < units.length,
103
109
  };
104
110
  }
111
+ /** Styled gap cursor cell — background highlight, not inverse video (safer for Thai terminals). */
112
+ export function inputCursorCell() {
113
+ return chalk.bgCyan.black(' ');
114
+ }
115
+ /**
116
+ * Render the whole input line as one string so Thai clusters are shaped once (no split Text nodes).
117
+ * The cursor is a single highlighted gap cell between grapheme clusters, never on a letter.
118
+ */
119
+ export function formatInputLineDisplay(vp, opts) {
120
+ const lead = vp.lead ? chalk.dim(SCROLL_LEAD) : '';
121
+ const tail = vp.tail ? chalk.dim(SCROLL_TAIL) : '';
122
+ const queue = opts?.queueHint ? chalk.dim(opts.queueHint) : '';
123
+ return `${lead}${vp.before}${inputCursorCell()}${vp.after}${tail}${queue}`;
124
+ }
125
+ /** Multiline input: same single-string cursor treatment at the grapheme insert point. */
126
+ export function formatMultilineInputDisplay(value, cursor, opts) {
127
+ const insertAt = cursorInsertGraphemeIndex(value, cursor);
128
+ const graphemes = graphemesOf(value);
129
+ const before = graphemes.slice(0, insertAt).join('');
130
+ const after = graphemes.slice(insertAt).join('');
131
+ const queue = opts?.queueHint ? chalk.dim(opts.queueHint) : '';
132
+ return `${before}${inputCursorCell()}${after}${queue}`;
133
+ }
@@ -1,11 +1,11 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
3
  import { memo, useRef } from 'react';
4
+ import { clipToWidth } from './text-width.js';
4
5
  const FENCE_RE = /^\s*(`{3,}|~{3,})(.*)$/;
6
+ // display-width aware so Thai/emoji/code lines in transcript truncate at the right column
5
7
  function clip(text, width) {
6
- if (width <= 0)
7
- return '';
8
- return text.length > width ? `${text.slice(0, Math.max(0, width - 3))}...` : text;
8
+ return clipToWidth(text, width, '...');
9
9
  }
10
10
  function bodyWidth(columns) {
11
11
  return Math.max(24, Math.min(Math.max(30, columns - 4), 100));
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
3
  import { HOTKEYS } from '../hotkeys.js';
4
+ import { clipToWidth, padEndToWidth } from './text-width.js';
4
5
  const MIN_OVERLAY_COLUMNS = 42;
5
6
  const MAX_OVERLAY_COLUMNS = 96;
6
7
  const MODEL_WINDOW = 10;
@@ -11,6 +12,22 @@ const TASK_WINDOW = 10;
11
12
  const TOOL_WINDOW = 10;
12
13
  const DEFAULT_PAGER_PAGE_SIZE = 12;
13
14
  const COMPLETION_WINDOW = 8;
15
+ /** Item rows + footer hint row — fixed slot height so / and @ completions do not bounce the input dock. */
16
+ export const COMPLETION_OVERLAY_SLOT_LINES = COMPLETION_WINDOW + 1;
17
+ /** Terminal rows reserved above the input while slash/@ completion is active (content + border). */
18
+ export const COMPLETION_OVERLAY_RESERVED_ROWS = COMPLETION_OVERLAY_SLOT_LINES + 2;
19
+ export function shouldReserveCompletionSlot(input, items) {
20
+ if (items.length > 0)
21
+ return true;
22
+ return input.startsWith('/') || input.includes('@');
23
+ }
24
+ export function completionOverlaySlotLines(items, selected, columns) {
25
+ const active = completionOverlayLines(items, selected, columns);
26
+ const slot = active.length ? [...active] : [];
27
+ while (slot.length < COMPLETION_OVERLAY_SLOT_LINES)
28
+ slot.push('');
29
+ return slot.slice(0, COMPLETION_OVERLAY_SLOT_LINES);
30
+ }
14
31
  function OverlayBox({ children, columns }) {
15
32
  const width = overlayWidth(columns);
16
33
  return (_jsx(Box, { borderStyle: "double", borderColor: "cyan", flexDirection: "column", marginBottom: 1, paddingX: 1, width: width, children: children }));
@@ -18,10 +35,10 @@ function OverlayBox({ children, columns }) {
18
35
  function overlayWidth(columns) {
19
36
  return Math.max(34, Math.min(Math.max(MIN_OVERLAY_COLUMNS, Math.floor(columns || 80) - 4), MAX_OVERLAY_COLUMNS));
20
37
  }
38
+ // display-width aware (Thai marks 0, emoji/CJK 2) so the bordered overlay columns + right edge stay
39
+ // aligned for non-ASCII session titles / skill names / mcp targets / model labels.
21
40
  function clip(text, width) {
22
- if (width <= 0)
23
- return '';
24
- return text.length > width ? `${text.slice(0, Math.max(0, width - 1))}…` : text;
41
+ return clipToWidth(text, width);
25
42
  }
26
43
  export function completionOverlayLines(items, selected, columns) {
27
44
  if (!items.length)
@@ -36,21 +53,22 @@ export function completionOverlayLines(items, selected, columns) {
36
53
  const lines = visible.map((item, offset) => {
37
54
  const index = start + offset;
38
55
  const cursor = index === safeSelected ? '>' : ' ';
39
- return `${cursor} ${clip(item.display, commandWidth).padEnd(commandWidth)} ${clip(item.meta, metaWidth)}`;
56
+ return `${cursor} ${padEndToWidth(clip(item.display, commandWidth), commandWidth)} ${clip(item.meta, metaWidth)}`;
40
57
  });
41
58
  lines.push('↑↓ select · Tab/Enter complete');
42
59
  return lines;
43
60
  }
44
- export function CompletionOverlay({ columns, items, selected }) {
61
+ export function CompletionOverlay({ columns, items, selected, reserved = false }) {
62
+ if (!reserved)
63
+ return null;
45
64
  const width = Math.max(28, Math.min(Math.max(34, columns - 6), MAX_OVERLAY_COLUMNS));
46
65
  const innerWidth = Math.max(1, width - 4);
47
- const lines = completionOverlayLines(items, selected, columns);
48
- if (!lines.length)
49
- return null;
50
- return (_jsx(Box, { borderStyle: "round", borderColor: "cyan", flexDirection: "column", marginBottom: 1, paddingX: 1, width: width, children: lines.map((line, index) => {
51
- const isActive = line.startsWith('>');
52
- return (_jsx(Text, { color: isActive ? 'green' : undefined, dimColor: !isActive, inverse: isActive, wrap: "truncate-end", children: clip(line, innerWidth) }, `${index}-${line}`));
53
- }) }));
66
+ const lines = completionOverlaySlotLines(items, selected, columns);
67
+ const visible = items.length > 0;
68
+ return (_jsx(Box, { flexDirection: "column", height: COMPLETION_OVERLAY_RESERVED_ROWS, justifyContent: "flex-end", marginBottom: 1, width: width, children: visible ? (_jsx(Box, { borderStyle: "round", borderColor: "cyan", flexDirection: "column", paddingX: 1, children: lines.map((line, index) => {
69
+ const isActive = line.startsWith('>');
70
+ return (_jsx(Text, { color: isActive ? 'green' : undefined, dimColor: !isActive, inverse: isActive, wrap: "truncate-end", children: clip(line || ' ', innerWidth) }, `${index}-${line || 'blank'}`));
71
+ }) })) : null }));
54
72
  }
55
73
  export function hotkeyOverlayLines(columns) {
56
74
  const width = overlayWidth(columns);
@@ -96,7 +114,7 @@ export function modelOverlayLines(overlay, columns) {
96
114
  for (const [offset, provider] of visible.entries()) {
97
115
  const index = window.start + offset;
98
116
  const cursor = index === overlay.selected ? '>' : ' ';
99
- lines.push(`${cursor} ${clip(provider.label, nameWidth).padEnd(nameWidth)} ${provider.modelCount} models · ${provider.status}`);
117
+ lines.push(`${cursor} ${padEndToWidth(clip(provider.label, nameWidth), nameWidth)} ${provider.modelCount} models · ${provider.status}`);
100
118
  }
101
119
  if (window.end < overlay.providers.length)
102
120
  lines.push(`... ${overlay.providers.length - window.end} more`);
@@ -115,7 +133,7 @@ export function modelOverlayLines(overlay, columns) {
115
133
  const index = window.start + offset;
116
134
  const cursor = index === overlay.selected ? '>' : ' ';
117
135
  const current = option.current ? '*' : ' ';
118
- lines.push(`${cursor}${current} ${clip(option.label, optionWidth).padEnd(optionWidth)} ${clip(option.meta, metaWidth)}`);
136
+ lines.push(`${cursor}${current} ${padEndToWidth(clip(option.label, optionWidth), optionWidth)} ${clip(option.meta, metaWidth)}`);
119
137
  }
120
138
  if (window.end < overlay.options.length)
121
139
  lines.push(`... ${overlay.options.length - window.end} more`);
@@ -169,7 +187,7 @@ export function mcpOverlayLines(overlay, columns) {
169
187
  for (const [offset, server] of visible.entries()) {
170
188
  const index = window.start + offset;
171
189
  const cursor = index === overlay.selected ? '>' : ' ';
172
- lines.push(`${cursor} ${clip(server.name, nameWidth).padEnd(nameWidth)} ${server.transport.padEnd(5)} ${clip(server.target, targetWidth)}`);
190
+ lines.push(`${cursor} ${padEndToWidth(clip(server.name, nameWidth), nameWidth)} ${server.transport.padEnd(5)} ${clip(server.target, targetWidth)}`);
173
191
  }
174
192
  if (window.end < overlay.servers.length)
175
193
  lines.push(`... ${overlay.servers.length - window.end} more`);
@@ -260,7 +278,7 @@ export function skillsOverlayLines(overlay, columns) {
260
278
  for (const [offset, skill] of visible.entries()) {
261
279
  const index = window.start + offset;
262
280
  const cursor = index === overlay.selected ? '>' : ' ';
263
- lines.push(`${cursor} ${clip(skill.name, nameWidth).padEnd(nameWidth)} ${clip(skill.description || '(no description)', descWidth)}`);
281
+ lines.push(`${cursor} ${padEndToWidth(clip(skill.name, nameWidth), nameWidth)} ${clip(skill.description || '(no description)', descWidth)}`);
264
282
  }
265
283
  if (window.end < overlay.skills.length)
266
284
  lines.push(`... ${overlay.skills.length - window.end} more`);
@@ -304,7 +322,7 @@ export function toolsOverlayLines(overlay, columns) {
304
322
  for (const [offset, tool] of visible.entries()) {
305
323
  const index = window.start + offset;
306
324
  const cursor = index === overlay.selected ? '>' : ' ';
307
- lines.push(`${cursor} ${clip(tool.group, groupWidth).padEnd(groupWidth)} ${clip(tool.name, nameWidth).padEnd(nameWidth)} ${clip(tool.summary, summaryWidth)}`);
325
+ lines.push(`${cursor} ${padEndToWidth(clip(tool.group, groupWidth), groupWidth)} ${padEndToWidth(clip(tool.name, nameWidth), nameWidth)} ${clip(tool.summary, summaryWidth)}`);
308
326
  }
309
327
  if (window.end < overlay.tools.length)
310
328
  lines.push(`... ${overlay.tools.length - window.end} more`);
@@ -452,7 +470,7 @@ export function sessionsOverlayLines(overlay, columns) {
452
470
  const cursor = index === overlay.selected ? '>' : ' ';
453
471
  const title = sessionTitle(session, overlay.currentCwd);
454
472
  const meta = `${session.model} · ${shortDate(session.updated)}`;
455
- lines.push(`${cursor} ${clip(session.id, idWidth).padEnd(idWidth)} ${clip(title, titleWidth).padEnd(titleWidth)} ${clip(meta, metaWidth)}`);
473
+ lines.push(`${cursor} ${padEndToWidth(clip(session.id, idWidth), idWidth)} ${padEndToWidth(clip(title, titleWidth), titleWidth)} ${clip(meta, metaWidth)}`);
456
474
  }
457
475
  if (window.end < overlay.sessions.length)
458
476
  lines.push(`... ${overlay.sessions.length - window.end} more`);
package/dist/ui/queue.js CHANGED
@@ -1,7 +1,8 @@
1
+ import { clipToWidth } from './text-width.js';
1
2
  export const QUEUE_WINDOW = 3;
3
+ // display-width aware: queued items are user prompts (often Thai) — clip by columns, not code units
2
4
  export function compactPreview(text, width) {
3
- const max = Math.max(8, width);
4
- return text.length > max ? `${text.slice(0, Math.max(0, max - 1))}…` : text;
5
+ return clipToWidth(text, Math.max(8, width));
5
6
  }
6
7
  export function getQueueWindow(queueLength, activeIndex = null) {
7
8
  const start = activeIndex === null ? 0 : Math.max(0, Math.min(activeIndex - 1, Math.max(0, queueLength - QUEUE_WINDOW)));
package/dist/ui/render.js CHANGED
@@ -4,7 +4,7 @@ import { render } from 'ink';
4
4
  import { App } from './app.js';
5
5
  import { SetupWizard } from './setup.js';
6
6
  import { BrainWizard } from './brain-wizard.js';
7
- import { PersonaWizard } from './persona-wizard.js';
7
+ import { PersonaWizard, PersonaOverlay } from './persona-wizard.js';
8
8
  import { saveKey, saveGlobalConfig, saveBrainPath } from '../config.js';
9
9
  import { BRAND } from '../brand.js';
10
10
  // Ink needs raw mode; mounting on a non-TTY stdin (piped/redirected/cron/CI) throws
@@ -33,6 +33,8 @@ export function Root({ needsSetup, appProps, clearScreen }) {
33
33
  const [model, setModel] = useState(appProps.initialModel);
34
34
  const [brainNote, setBrainNote] = useState(undefined);
35
35
  const [locale, setLocale] = useState('th');
36
+ // carried across the brain phase so the persona questionnaire still runs after brain creation
37
+ const [setupPersona, setSetupPersona] = useState(false);
36
38
  // เข้า REPL: เคลียร์จอที่เต็มไปด้วย wizard ก่อน → banner "Sanook AI" เด้งบนจอว่าง
37
39
  const enterApp = () => {
38
40
  clearScreen?.();
@@ -51,8 +53,14 @@ export function Root({ needsSetup, appProps, clearScreen }) {
51
53
  });
52
54
  setModel(r.model);
53
55
  setLocale(r.locale);
56
+ setSetupPersona(r.setupPersona ?? false);
57
+ // setup → (brain?) → (persona?) → REPL. The persona phase runs after brain creation when both
58
+ // were requested, so a user who creates a vault AND fills the questionnaire isn't asked twice
59
+ // (the questionnaire prefills from the brain-seeded name).
54
60
  if (r.createBrain)
55
61
  setPhase('brain');
62
+ else if (r.setupPersona)
63
+ setPhase('persona');
56
64
  else
57
65
  enterApp();
58
66
  })();
@@ -71,8 +79,9 @@ export function Root({ needsSetup, appProps, clearScreen }) {
71
79
  try {
72
80
  const res = await scaffoldBrain(target, {
73
81
  ...BRAIN_DEFAULTS,
74
- ownerName: a.ownerName,
75
- aiName: a.aiName,
82
+ // vault scaffold needs a non-empty name → apply the default when the user skipped (a.* === '')
83
+ ownerName: a.ownerName || BRAIN_DEFAULTS.ownerName,
84
+ aiName: a.aiName || BRAIN_DEFAULTS.aiName,
76
85
  autonomy: a.autonomy,
77
86
  language,
78
87
  today,
@@ -81,12 +90,12 @@ export function Root({ needsSetup, appProps, clearScreen }) {
81
90
  const wired = await wireBrainMcp(target).catch(() => 'skip');
82
91
  const linked = await linkBrainToProject({ brainPath: target, cwd: process.cwd(), today }).catch(() => null);
83
92
  // เซฟ persona/identity ที่เก็บใน wizard ลง durable memory (owner ground-truth) → agent จำได้ทันที
93
+ // ส่ง RAW value (a.ownerName อาจเป็น '') — seedPersonaMemory จะข้ามค่าว่างเอง ไม่ seed 'Owner' placeholder
84
94
  const seeded = await seedPersonaMemory({
85
95
  ownerName: a.ownerName,
86
96
  aiName: a.aiName,
87
97
  language,
88
98
  autonomy: a.autonomy,
89
- defaults: { ownerName: BRAIN_DEFAULTS.ownerName, aiName: BRAIN_DEFAULTS.aiName },
90
99
  }).catch(() => 0);
91
100
  const linkNote = linked?.projectRelDir
92
101
  ? ` · project ${linked.projectRelDir} · ${linked.memoryCreated ? 'created' : 'linked'} ${BRAND.memoryFileName}`
@@ -98,11 +107,22 @@ export function Root({ needsSetup, appProps, clearScreen }) {
98
107
  catch (e) {
99
108
  setBrainNote(`⚠ สร้าง second-brain ไม่สำเร็จ: ${e.message} — ลองใหม่ด้วย ${'`'}sanook brain init${'`'}`);
100
109
  }
101
- enterApp();
110
+ if (setupPersona)
111
+ setPhase('persona');
112
+ else
113
+ enterApp();
102
114
  })();
103
115
  };
104
116
  return _jsx(BrainWizard, { onComplete: onComplete });
105
117
  }
118
+ if (phase === 'persona') {
119
+ // full persona questionnaire (PersonaOverlay loads existing answers — incl. a brain-seeded name —
120
+ // persists to auto-memory + vault, then reports). Its note is appended to any brain note above.
121
+ return (_jsx(PersonaOverlay, { onDone: (msg) => {
122
+ setBrainNote((n) => (n ? `${n}\n${msg}` : msg));
123
+ enterApp();
124
+ } }));
125
+ }
106
126
  // App mount สดตอน phase = 'app' → useState(initialModel) หยิบ model ที่เลือกจาก wizard ถูกต้อง
107
127
  return _jsx(App, { ...appProps, initialModel: model, initialNote: brainNote ?? appProps.initialNote });
108
128
  }
@@ -140,8 +160,8 @@ export function startBrainSetup() {
140
160
  const target = expandHome(a.path);
141
161
  const res = await scaffoldBrain(target, {
142
162
  ...BRAIN_DEFAULTS,
143
- ownerName: a.ownerName,
144
- aiName: a.aiName,
163
+ ownerName: a.ownerName || BRAIN_DEFAULTS.ownerName,
164
+ aiName: a.aiName || BRAIN_DEFAULTS.aiName,
145
165
  autonomy: a.autonomy,
146
166
  today,
147
167
  });
@@ -152,7 +172,6 @@ export function startBrainSetup() {
152
172
  ownerName: a.ownerName,
153
173
  aiName: a.aiName,
154
174
  autonomy: a.autonomy,
155
- defaults: { ownerName: BRAIN_DEFAULTS.ownerName, aiName: BRAIN_DEFAULTS.aiName },
156
175
  }).catch(() => 0);
157
176
  unmount();
158
177
  const linkLine = linked?.projectRelDir ? `\n linked repo → ${linked.projectRelDir} · ${BRAND.memoryFileName} in cwd` : '';
@@ -4,15 +4,13 @@ import { homedir } from 'node:os';
4
4
  import { useState } from 'react';
5
5
  import { BRAND } from '../brand.js';
6
6
  import { TOOL_CATALOG } from '../tool-catalog.js';
7
+ import { clipToWidth } from './text-width.js';
7
8
  const MIN_PANEL_COLUMNS = 48;
8
9
  const COMPACT_PANEL_COLUMNS = 72;
9
10
  const MAX_PANEL_COLUMNS = 100;
10
11
  const PREVIEW_LIMIT = 4;
11
- const clip = (text, width) => {
12
- if (width <= 0)
13
- return '';
14
- return text.length > width ? `${text.slice(0, Math.max(0, width - 1))}…` : text;
15
- };
12
+ // display-width aware (Thai/emoji) so panel rows align with the border
13
+ const clip = (text, width) => clipToWidth(text, width);
16
14
  function displayDir(cwd) {
17
15
  return (cwd ?? process.cwd()).replace(homedir(), '~');
18
16
  }
package/dist/ui/setup.js CHANGED
@@ -31,6 +31,7 @@ export function SetupWizard({ onComplete }) {
31
31
  const [codexDeviceAttempt, setCodexDeviceAttempt] = useState(0);
32
32
  const [permissionMode, setPermissionMode] = useState('ask');
33
33
  const [gatewayHint, setGatewayHint] = useState();
34
+ const [createBrain, setCreateBrain] = useState(false);
34
35
  const cfg = provider ? PROVIDERS[provider] : undefined;
35
36
  const providerOptions = setupProviderOptions();
36
37
  const providerMenuLines = setupProviderMenuLines();
@@ -106,23 +107,9 @@ export function SetupWizard({ onComplete }) {
106
107
  };
107
108
  }, [step, cfg, key]);
108
109
  const modelOptions = cfg ? mergeModelOptions(cfg, remote) : [];
109
- const finish = (createBrain) => {
110
- if (createBrain) {
111
- onComplete({
112
- locale,
113
- provider,
114
- model,
115
- envVar: cfg?.envVar ?? '',
116
- key,
117
- permissionMode,
118
- gatewayHint,
119
- createBrain: true,
120
- });
121
- return;
122
- }
123
- setStep('complete');
124
- };
125
- const finishRepl = () => onComplete({
110
+ // single terminal exit — carries both post-setup branch flags (brain creation + persona questionnaire).
111
+ // Root sequences them: setup → (brain?) → (persona?) → REPL.
112
+ const complete = (flags) => onComplete({
126
113
  locale,
127
114
  provider,
128
115
  model,
@@ -130,7 +117,8 @@ export function SetupWizard({ onComplete }) {
130
117
  key,
131
118
  permissionMode,
132
119
  gatewayHint,
133
- createBrain: false,
120
+ createBrain: flags.createBrain,
121
+ setupPersona: flags.setupPersona,
134
122
  });
135
123
  const backToProvider = () => {
136
124
  setProvider('');
@@ -252,5 +240,19 @@ export function SetupWizard({ onComplete }) {
252
240
  } })] })), step === 'brain-offer' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: m.stepBrain }), _jsx(Text, { color: "gray", children: m.brainQuestion }), _jsx(Select, { options: [
253
241
  { label: m.brainYes, value: 'yes' },
254
242
  { label: m.brainNo, value: 'no' },
255
- ], onChange: (v) => finish(v === 'yes') })] })), step === 'complete' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: m.stepComplete }), _jsx(Text, { bold: true, children: m.completeTitle }), _jsx(Text, { color: "gray", children: m.completeBody }), _jsxs(Text, { color: "cyan", children: [" ", m.completeDashboard, ": ", BRAND.cliName, " dashboard"] }), gatewayHint ? _jsxs(Text, { color: "yellow", children: [" Gateway: ", gatewayHint] }) : null, _jsxs(Text, { color: "gray", children: [" permissionMode: ", permissionMode] }), _jsx(Select, { options: [{ label: m.completeRepl, value: 'repl' }], onChange: () => finishRepl() })] }))] }));
243
+ ], onChange: (v) => {
244
+ setCreateBrain(v === 'yes');
245
+ setStep('persona-offer');
246
+ } })] })), step === 'persona-offer' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: m.stepPersona }), _jsx(Text, { color: "gray", children: m.personaQuestion }), _jsx(Select, { options: [
247
+ { label: m.personaYes, value: 'yes' },
248
+ { label: m.personaNo, value: 'no' },
249
+ ], onChange: (v) => {
250
+ const setupPersona = v === 'yes';
251
+ // jump straight to onComplete when a follow-up phase (brain/persona) will run; otherwise
252
+ // show the summary 'complete' screen before entering the REPL.
253
+ if (setupPersona || createBrain)
254
+ complete({ createBrain, setupPersona });
255
+ else
256
+ setStep('complete');
257
+ } })] })), step === 'complete' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: m.stepComplete }), _jsx(Text, { bold: true, children: m.completeTitle }), _jsx(Text, { color: "gray", children: m.completeBody }), _jsxs(Text, { color: "cyan", children: [" ", m.completeDashboard, ": ", BRAND.cliName, " dashboard"] }), gatewayHint ? _jsxs(Text, { color: "yellow", children: [" Gateway: ", gatewayHint] }) : null, _jsxs(Text, { color: "gray", children: [" permissionMode: ", permissionMode] }), _jsx(Select, { options: [{ label: m.completeRepl, value: 'repl' }], onChange: () => complete({ createBrain: false, setupPersona: false }) })] }))] }));
256
258
  }
package/dist/ui/status.js CHANGED
@@ -1,8 +1,6 @@
1
- const clip = (text, width) => {
2
- if (width <= 0)
3
- return '';
4
- return text.length > width ? `${text.slice(0, Math.max(0, width - 1))}…` : text;
5
- };
1
+ import { clipToWidth, displayWidth, padEndToWidth } from './text-width.js';
2
+ // display-width aware (Thai marks 0, emoji/CJK 2) so the two-column footer stays aligned
3
+ const clip = (text, width) => clipToWidth(text, width);
6
4
  export function statusSegments(columns) {
7
5
  const width = Math.max(20, Math.floor(columns || 80));
8
6
  return {
@@ -25,7 +23,7 @@ export function statusRuleWidths(columns, rightLabel, minLeftContent = 0) {
25
23
  const maxRight = Math.max(0, width - separatorWidth - leftFloor);
26
24
  if (!rightLabel || maxRight <= 0)
27
25
  return { leftWidth: width, rightWidth: 0, separatorWidth: 0 };
28
- const rightWidth = Math.min(rightLabel.length, maxRight);
26
+ const rightWidth = Math.min(displayWidth(rightLabel), maxRight);
29
27
  return {
30
28
  leftWidth: Math.max(1, width - separatorWidth - rightWidth),
31
29
  rightWidth,
@@ -58,12 +56,13 @@ export function footerStatus({ branch, backgroundTaskCount = 0, busy = false, co
58
56
  if (!segments.cwd || !cwd)
59
57
  return clip(left, width);
60
58
  const right = formatCwd(cwd, branch);
61
- const minRight = width >= 96 ? Math.min(right.length, 22) : Math.min(right.length, 12);
62
- const minLeft = Math.min(width, Math.max(20, Math.min(left.length, width - 3 - minRight)));
59
+ const rightW = displayWidth(right);
60
+ const minRight = width >= 96 ? Math.min(rightW, 22) : Math.min(rightW, 12);
61
+ const minLeft = Math.min(width, Math.max(20, Math.min(displayWidth(left), width - 3 - minRight)));
63
62
  const rule = statusRuleWidths(width, right, minLeft);
64
63
  if (!rule.rightWidth)
65
64
  return clip(left, width);
66
- const leftPart = clip(left, rule.leftWidth).padEnd(rule.leftWidth);
65
+ const leftPart = padEndToWidth(clip(left, rule.leftWidth), rule.leftWidth);
67
66
  const rightPart = clip(right, rule.rightWidth);
68
67
  return `${leftPart}${' '.repeat(rule.separatorWidth)}${rightPart}`;
69
68
  }
@@ -0,0 +1,52 @@
1
+ import stringWidth from 'string-width';
2
+ // Display-WIDTH-aware text helpers. The REPL renders Thai (combining vowels/tone marks = 0 cells),
3
+ // emoji (2 cells), and CJK (2 cells); measuring those with String.prototype.length / .slice / .padEnd
4
+ // counts UTF-16 code units, not terminal columns, so fixed-width layouts (banner box, status bar,
5
+ // tool-trail columns, transcript truncation) drift, the right edge misaligns, and borders look broken
6
+ // — differently across terminals. These helpers measure with string-width (the same table Ink uses to
7
+ // wrap) and cut on grapheme boundaries, so a base char and its marks never split. For pure ASCII the
8
+ // output is byte-identical to the old .length math (display width == length), so capable terminals are
9
+ // unaffected; only Thai/emoji/CJK lines change — toward correct alignment.
10
+ const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
11
+ /** split into grapheme clusters (base char + combining marks / emoji ZWJ sequences stay together) */
12
+ function graphemes(text) {
13
+ return Array.from(segmenter.segment(text), (s) => s.segment);
14
+ }
15
+ /** terminal display width in columns (Thai marks 0, emoji/CJK 2) */
16
+ export function displayWidth(text) {
17
+ return stringWidth(text);
18
+ }
19
+ /**
20
+ * Truncate `text` to at most `maxWidth` DISPLAY columns, appending `ellipsis` when content is cut.
21
+ * Cuts on grapheme boundaries (never splits a Thai cluster or emoji). When maxWidth is too small even
22
+ * for the ellipsis, fills with as many whole clusters as fit (no ellipsis).
23
+ */
24
+ export function clipToWidth(text, maxWidth, ellipsis = '…') {
25
+ if (maxWidth <= 0)
26
+ return '';
27
+ if (stringWidth(text) <= maxWidth)
28
+ return text;
29
+ const ellW = stringWidth(ellipsis);
30
+ const budget = maxWidth > ellW ? maxWidth - ellW : maxWidth;
31
+ const withEllipsis = maxWidth > ellW;
32
+ let out = '';
33
+ let w = 0;
34
+ for (const g of graphemes(text)) {
35
+ const gw = Math.max(1, stringWidth(g)); // guard: a stray 0-width cluster still consumes one slot
36
+ if (w + gw > budget)
37
+ break;
38
+ out += g;
39
+ w += gw;
40
+ }
41
+ return withEllipsis ? out + ellipsis : out;
42
+ }
43
+ /** Pad the END with spaces to a target DISPLAY width (returns text unchanged if already ≥ target). */
44
+ export function padEndToWidth(text, target) {
45
+ const pad = target - stringWidth(text);
46
+ return pad > 0 ? text + ' '.repeat(pad) : text;
47
+ }
48
+ /** Pad the START with spaces to a target DISPLAY width (returns text unchanged if already ≥ target). */
49
+ export function padStartToWidth(text, target) {
50
+ const pad = target - stringWidth(text);
51
+ return pad > 0 ? ' '.repeat(pad) + text : text;
52
+ }