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/CHANGELOG.md +23 -0
- package/dist/commands.js +14 -0
- package/dist/i18n/en.js +5 -1
- package/dist/i18n/th.js +5 -1
- package/dist/loop.js +48 -19
- package/dist/memory-store.js +20 -7
- package/dist/memory.js +57 -6
- package/dist/persona.js +10 -0
- package/dist/prompt-safety.js +13 -0
- package/dist/prompt-size.js +5 -3
- package/dist/ui/app.js +38 -25
- package/dist/ui/banner.js +4 -6
- package/dist/ui/brain-wizard.js +6 -4
- package/dist/ui/input-view.js +55 -26
- package/dist/ui/markdown.js +3 -3
- package/dist/ui/overlay.js +36 -18
- package/dist/ui/queue.js +3 -2
- package/dist/ui/render.js +27 -8
- package/dist/ui/session-panel.js +3 -5
- package/dist/ui/setup.js +21 -19
- package/dist/ui/status.js +8 -9
- package/dist/ui/text-width.js +52 -0
- package/dist/ui/thinking-panel.js +4 -3
- package/dist/ui/tool-trail.js +4 -4
- package/package.json +1 -1
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
|
-
|
|
28
|
-
|
|
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
|
|
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. */
|
package/dist/ui/brain-wizard.js
CHANGED
|
@@ -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
|
-
|
|
14
|
-
|
|
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()
|
|
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()
|
|
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' },
|
package/dist/ui/input-view.js
CHANGED
|
@@ -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
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// Fix: the cursor highlights a WHOLE grapheme cluster (base + all its marks).
|
|
9
|
+
// 1) block cursor on Thai text — splitting 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
|
|
30
|
-
export function
|
|
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]
|
|
35
|
-
|
|
36
|
-
else
|
|
37
|
-
break;
|
|
35
|
+
if (bounds[i] === clamped)
|
|
36
|
+
return i;
|
|
38
37
|
}
|
|
39
|
-
return
|
|
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
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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,
|
|
62
|
-
at:
|
|
63
|
-
after:
|
|
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:
|
|
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
|
+
}
|
package/dist/ui/markdown.js
CHANGED
|
@@ -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
|
-
|
|
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));
|
package/dist/ui/overlay.js
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
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 =
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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)
|
|
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)
|
|
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)
|
|
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)
|
|
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)
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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` : '';
|
package/dist/ui/session-panel.js
CHANGED
|
@@ -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
|
-
|
|
12
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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:
|
|
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) =>
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
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
|
|
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
|
|
62
|
-
const
|
|
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)
|
|
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
|
+
}
|