sanook-cli 0.5.11 → 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 +14 -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 +13 -8
- package/dist/ui/banner.js +4 -6
- package/dist/ui/brain-wizard.js +6 -4
- package/dist/ui/input-view.js +27 -3
- package/dist/ui/markdown.js +3 -3
- package/dist/ui/overlay.js +11 -10
- 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/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.13
|
|
4
|
+
|
|
5
|
+
### Owner memory (Codex + all providers)
|
|
6
|
+
|
|
7
|
+
- **Codex delegate** — `codex:gpt-5.5` now prepends `<owner_persona>` + `<auto_memory>` + brain/project context into every `codex exec` prompt (previously skipped all durable memory).
|
|
8
|
+
- **SDK providers** — inject `<owner_persona>` into the static system prompt on every turn.
|
|
9
|
+
- **System instructions** — when asked what you remember about the user, answer from persona/memory blocks instead of denying cross-session memory.
|
|
10
|
+
|
|
11
|
+
## 0.5.12
|
|
12
|
+
|
|
13
|
+
### Thai input cursor
|
|
14
|
+
|
|
15
|
+
- **Cursor** — render the whole input line as one ANSI string with a cyan gap cell (`bgCyan`), not split `inverse` Text nodes that break Thai grapheme shaping and cover vowel marks.
|
|
16
|
+
|
|
3
17
|
## 0.5.11
|
|
4
18
|
|
|
5
19
|
### REPL layout stability
|
package/dist/commands.js
CHANGED
|
@@ -18,6 +18,7 @@ export const HELP_TEXT = `คำสั่ง:
|
|
|
18
18
|
/setup ดูขั้นตอน setup wizard (model · agent · tools · gateway · brain)
|
|
19
19
|
/dashboard เปิด Sanook Dashboard (local web UI)
|
|
20
20
|
/persona ตั้งค่า persona (ใครคุณเป็น + อยากให้ AI ทำงานยังไง)
|
|
21
|
+
/goal [text] ตั้งเป้าหมาย/โฟกัสปัจจุบัน (จำข้ามทุก session) — /goal เปล่า = วิธีใช้
|
|
21
22
|
/personality [name]
|
|
22
23
|
ดู/ตั้ง personality overlay
|
|
23
24
|
/details [thinking|tools] [hidden|collapsed|expanded]
|
|
@@ -217,6 +218,18 @@ export function parseCommand(input, ctx) {
|
|
|
217
218
|
};
|
|
218
219
|
case 'persona':
|
|
219
220
|
return { handled: true, action: 'personaSetup', message: 'เปิด persona wizard…' };
|
|
221
|
+
case 'goal': {
|
|
222
|
+
// /goal <text> sets the durable "current focus" (persona `goals` field) — remembered across
|
|
223
|
+
// every session via <owner_persona>. /goal alone shows usage (run /persona to edit the full set).
|
|
224
|
+
const goal = args.join(' ').trim();
|
|
225
|
+
if (!goal) {
|
|
226
|
+
return {
|
|
227
|
+
handled: true,
|
|
228
|
+
message: `ใช้: /goal <เป้าหมาย/โฟกัสตอนนี้>\n เก็บลง persona (AI จำข้ามทุก session) · ดู/แก้ทั้งหมด: /persona`,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
return { handled: true, action: 'goalSet', goalText: goal, message: `🎯 ตั้งเป้าหมาย → ${goal}` };
|
|
232
|
+
}
|
|
220
233
|
case 'personality': {
|
|
221
234
|
const raw = args.join(' ').trim();
|
|
222
235
|
if (!raw)
|
|
@@ -325,6 +338,7 @@ export const BUILTIN_COMMANDS = new Set([
|
|
|
325
338
|
'exit',
|
|
326
339
|
'model',
|
|
327
340
|
'personality',
|
|
341
|
+
'goal',
|
|
328
342
|
'details',
|
|
329
343
|
'platforms',
|
|
330
344
|
'trail',
|
package/dist/i18n/en.js
CHANGED
|
@@ -11,7 +11,7 @@ export const en = {
|
|
|
11
11
|
stepTools: '7. Tools & MCP',
|
|
12
12
|
stepGateway: '8. Messaging gateway',
|
|
13
13
|
stepBrain: '9. Second brain workspace',
|
|
14
|
-
stepComplete: '
|
|
14
|
+
stepComplete: '11. Ready',
|
|
15
15
|
languageHint: 'You can change this later with sanook config set locale en|th',
|
|
16
16
|
languageEn: 'English',
|
|
17
17
|
languageTh: 'Thai (ภาษาไทย)',
|
|
@@ -47,6 +47,10 @@ export const en = {
|
|
|
47
47
|
brainQuestion: 'Create a second-brain workspace (Obsidian) for durable AI memory?',
|
|
48
48
|
brainYes: 'Yes — a few questions (name + path)',
|
|
49
49
|
brainNo: 'Skip for now (run sanook brain init later)',
|
|
50
|
+
stepPersona: '10. Personal profile (persona)',
|
|
51
|
+
personaQuestion: 'Set up your persona now? (name · language · tone · goals — the AI remembers you across sessions)',
|
|
52
|
+
personaYes: 'Yes — a few quick questions (recommended)',
|
|
53
|
+
personaNo: 'Skip for now (run sanook persona or /persona later)',
|
|
50
54
|
completeTitle: 'Setup complete',
|
|
51
55
|
completeBody: 'Your CLI is ready. Open the dashboard for config and sessions, or start chatting in the terminal.',
|
|
52
56
|
completeDashboard: 'Open Sanook Dashboard',
|
package/dist/i18n/th.js
CHANGED
|
@@ -11,7 +11,7 @@ export const th = {
|
|
|
11
11
|
stepTools: '7. Tools & MCP',
|
|
12
12
|
stepGateway: '8. Messaging gateway',
|
|
13
13
|
stepBrain: '9. second brain workspace',
|
|
14
|
-
stepComplete: '
|
|
14
|
+
stepComplete: '11. พร้อมใช้งาน',
|
|
15
15
|
languageHint: 'เปลี่ยนภาษาทีหลังได้: sanook config set locale en|th',
|
|
16
16
|
languageEn: 'English (ภาษาอังกฤษ)',
|
|
17
17
|
languageTh: 'ภาษาไทย',
|
|
@@ -47,6 +47,10 @@ export const th = {
|
|
|
47
47
|
brainQuestion: 'สร้าง second-brain workspace (Obsidian) สำหรับความจำ AI ข้าม session?',
|
|
48
48
|
brainYes: 'สร้างเลย — ตอบไม่กี่ข้อ (ชื่อ + ที่เก็บ)',
|
|
49
49
|
brainNo: 'ข้ามไปก่อน (สั่ง sanook brain init ทีหลังได้)',
|
|
50
|
+
stepPersona: '10. โปรไฟล์ส่วนตัว (persona)',
|
|
51
|
+
personaQuestion: 'ตั้งโปรไฟล์ persona เลยไหม? (ชื่อ · ภาษา · โทน · เป้าหมาย — AI จะจำคุณข้ามทุก session)',
|
|
52
|
+
personaYes: 'ตั้งเลย — ตอบไม่กี่ข้อ (แนะนำ)',
|
|
53
|
+
personaNo: 'ข้ามไปก่อน (สั่ง sanook persona หรือ /persona ทีหลังได้)',
|
|
50
54
|
completeTitle: 'ตั้งค่าเสร็จแล้ว',
|
|
51
55
|
completeBody: 'CLI พร้อมใช้แล้ว เปิด Dashboard จัดการ config/sessions หรือเริ่มแชทใน terminal',
|
|
52
56
|
completeDashboard: 'เปิด Sanook Dashboard',
|
package/dist/loop.js
CHANGED
|
@@ -4,7 +4,8 @@ import { resolveModel, specKey, parseSpec, PROVIDERS } from './providers/registr
|
|
|
4
4
|
import { CostMeter, SharedBudget } from './cost.js';
|
|
5
5
|
import { tools } from './tools/index.js';
|
|
6
6
|
import { agentCwd } from './agentContext.js';
|
|
7
|
-
import { loadMemory, loadAutoMemory, loadBrainContext } from './memory.js';
|
|
7
|
+
import { loadMemory, loadAutoMemory, loadBrainContext, loadDelegateContext, loadOwnerPersonaBlock } from './memory.js';
|
|
8
|
+
import { memoryStoreEpoch } from './memory-store.js';
|
|
8
9
|
import { buildTurnRetrieval, PROJECT_SOURCES } from './turn-retrieval.js';
|
|
9
10
|
import { loadSkills, renderAvailableSkills } from './skills.js';
|
|
10
11
|
import { maybeWrapHooks } from './hooks.js';
|
|
@@ -41,6 +42,7 @@ export const SYSTEM = `You are ${BRAND.agentName}, an autonomous coding agent ru
|
|
|
41
42
|
- If a skill in <available_skills> matches the task, load it with the skill tool BEFORE starting; use find_skills to search when unsure which fits.
|
|
42
43
|
- For work that splits into independent parts (explore N modules, review N angles), fan out with task_parallel instead of doing them serially; for one big exploration whose result you only need summarized, use a single task. Kick off a long job with task_spawn and keep working, then task_collect it later or task_cancel it if it is no longer needed.
|
|
43
44
|
- After finishing a multi-step task that worked and is likely to recur, use create_skill to save the procedure; use remember for durable facts/preferences.
|
|
45
|
+
- Owner identity and durable preferences are injected in <owner_persona> and <auto_memory> — when asked what you remember about the user (name, preferences, past setup), answer from those blocks; do not deny memory if facts are present.
|
|
44
46
|
- If the user asks for something on a schedule or recurring time ("ทุกๆ X", "ตอน X โมง", "every X", a future time), use schedule_task — the gateway (${BRAND.cliName} serve) runs it. Convert their phrasing to canonical when (every 30m / 09:00 / ISO).
|
|
45
47
|
- Be concise. Answer in the user's language. Show what you found, then the answer.
|
|
46
48
|
- Don't paste back file contents or large code blocks you just read or edited — the user already sees the diff/tool output; reference path:line instead. This keeps replies (and token cost) small without losing anything.`;
|
|
@@ -145,6 +147,12 @@ async function maybeWrapWithHeadroom(model) {
|
|
|
145
147
|
stack: 'sanook-cli',
|
|
146
148
|
});
|
|
147
149
|
}
|
|
150
|
+
// Per-session snapshot of the two memory blocks whose rendering drifts turn-to-turn (owner persona +
|
|
151
|
+
// auto-memory decay with Date.now()). Frozen for the life of a session+epoch so the system-prompt
|
|
152
|
+
// prefix stays byte-identical across read-only turns (prompt cache stays warm); a store write bumps
|
|
153
|
+
// memoryStoreEpoch() → key changes → reload, so a name/goal set mid-session is reflected next turn.
|
|
154
|
+
// Keyed by sessionId (gateway/serve runs many sessions in one process); capped to bound memory.
|
|
155
|
+
const personaSnapshots = new Map();
|
|
148
156
|
/**
|
|
149
157
|
* แกน harness — agent loop: LLM -> tool -> result -> loop จนเสร็จ
|
|
150
158
|
* multi-provider (BYOK) ผ่าน registry + cost meter + budget cap
|
|
@@ -153,23 +161,30 @@ async function runDelegate(opts) {
|
|
|
153
161
|
const { runCodex } = await import('./providers/codex.js');
|
|
154
162
|
const meter = new CostMeter(specKey(opts.model), opts.budgetUsd, agentContext.getStore()?.sharedBudget);
|
|
155
163
|
const { model } = parseSpec(opts.model);
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
164
|
+
const [context, priorParts] = await Promise.all([
|
|
165
|
+
loadDelegateContext(opts.cwd ?? agentCwd()),
|
|
166
|
+
Promise.resolve((opts.history ?? [])
|
|
167
|
+
.map((m) => {
|
|
168
|
+
const role = m.role === 'user' ? 'User' : m.role === 'assistant' ? 'Assistant' : '';
|
|
169
|
+
if (!role)
|
|
170
|
+
return '';
|
|
171
|
+
const c = typeof m.content === 'string'
|
|
172
|
+
? m.content
|
|
173
|
+
: Array.isArray(m.content)
|
|
174
|
+
? m.content.map((p) => (typeof p === 'object' && p && 'type' in p && p.type === 'text' ? p.text : '')).join('')
|
|
175
|
+
: '';
|
|
176
|
+
return c.trim() ? `${role}: ${c.trim()}` : '';
|
|
177
|
+
})
|
|
178
|
+
.filter(Boolean)
|
|
179
|
+
.join('\n\n')),
|
|
180
|
+
]);
|
|
181
|
+
const prompt = [
|
|
182
|
+
context,
|
|
183
|
+
priorParts ? `Previous conversation:\n${priorParts}` : '',
|
|
184
|
+
`Now: ${opts.prompt}`,
|
|
185
|
+
]
|
|
170
186
|
.filter(Boolean)
|
|
171
|
-
.join('\n\n');
|
|
172
|
-
const prompt = prior ? `Previous conversation:\n${prior}\n\n---\nNow: ${opts.prompt}` : opts.prompt;
|
|
187
|
+
.join('\n\n---\n');
|
|
173
188
|
// sandbox: plan/ask-mode → read-only (สกัด approval รายไฟล์ของ codex ไม่ได้ จึงไม่ให้แก้);
|
|
174
189
|
// auto (--yes / config auto) → workspace-write เพื่อให้ codex แก้ไฟล์ได้จริง (ไม่งั้นเป็น coding agent ที่แก้อะไรไม่ได้)
|
|
175
190
|
const sandbox = opts.planMode || (opts.permissionMode ?? 'ask') === 'ask' ? 'read-only' : 'workspace-write';
|
|
@@ -250,9 +265,17 @@ export async function runAgent(opts) {
|
|
|
250
265
|
let meter = new CostMeter(specKey(opts.model), opts.budgetUsd, sharedBudget);
|
|
251
266
|
// โหลด context: auto-memory + skills + git state + repo map + project SANOOK.md → system prompt
|
|
252
267
|
// sub-agent (opts.tools) ข้าม repo map (มี subset tool + prompt เฉพาะอยู่แล้ว — ประหยัด context)
|
|
253
|
-
|
|
268
|
+
// persona + auto-memory are frozen per (session, memory-epoch) so the cached prompt prefix doesn't
|
|
269
|
+
// shift every turn from decay re-ranking. Sub-agents (opts.tools) and sessionless runs skip the cache
|
|
270
|
+
// and always load fresh. A store write (remember/persona/goal) bumps the epoch → next turn reloads.
|
|
271
|
+
const snapSessionId = opts.tools ? undefined : opts.usageMeta?.sessionId;
|
|
272
|
+
const snapEpoch = memoryStoreEpoch();
|
|
273
|
+
const personaHit = snapSessionId ? personaSnapshots.get(snapSessionId) : undefined;
|
|
274
|
+
const personaCached = personaHit && personaHit.epoch === snapEpoch ? personaHit : undefined;
|
|
275
|
+
const [memory, autoMemory, ownerPersona, skills, git, brain, repoMap, tuning, config] = await Promise.all([
|
|
254
276
|
loadMemory(),
|
|
255
|
-
loadAutoMemory(),
|
|
277
|
+
personaCached ? Promise.resolve(personaCached.autoMemory) : loadAutoMemory(),
|
|
278
|
+
personaCached ? Promise.resolve(personaCached.ownerPersona) : loadOwnerPersonaBlock(),
|
|
256
279
|
loadSkills(),
|
|
257
280
|
gitContext(opts.cwd), // worktree ของ sub-agent ถ้ามี → git context สะท้อน tree ที่ถูกต้อง
|
|
258
281
|
loadBrainContext(opts.cwd ?? agentCwd()),
|
|
@@ -260,6 +283,11 @@ export async function runAgent(opts) {
|
|
|
260
283
|
agentTuning(), // cache TTL + thinking budget (อ่านจาก config/env)
|
|
261
284
|
loadConfig({}, opts.cwd ?? process.cwd()),
|
|
262
285
|
]);
|
|
286
|
+
if (snapSessionId && !personaCached) {
|
|
287
|
+
if (personaSnapshots.size > 100)
|
|
288
|
+
personaSnapshots.clear(); // bound long-running serve processes
|
|
289
|
+
personaSnapshots.set(snapSessionId, { epoch: snapEpoch, ownerPersona, autoMemory });
|
|
290
|
+
}
|
|
263
291
|
// self-retrieving brain: proactively surface vault/memory/session notes relevant to THIS prompt.
|
|
264
292
|
// Runs AFTER the gather so it can DEDUP against what's already statically injected (auto_memory +
|
|
265
293
|
// brain hot-files) — H8 showed memory hits were otherwise 100% duplicated. Sub-agents skip it like
|
|
@@ -291,6 +319,7 @@ export async function runAgent(opts) {
|
|
|
291
319
|
const staticSystem = [
|
|
292
320
|
SYSTEM + planSuffix + brainNudge,
|
|
293
321
|
personalityPrompt(config.personality),
|
|
322
|
+
ownerPersona,
|
|
294
323
|
autoMemory,
|
|
295
324
|
renderAvailableSkills(skills),
|
|
296
325
|
brain,
|
package/dist/memory-store.js
CHANGED
|
@@ -23,6 +23,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
23
23
|
import { z } from 'zod';
|
|
24
24
|
import { appHomePath, BRAND, persistenceEnabled } from './brand.js';
|
|
25
25
|
import { redactKey } from './providers/keys.js';
|
|
26
|
+
import { neutralizeBlockTags } from './prompt-safety.js';
|
|
26
27
|
// ---- enums / taxonomy ------------------------------------------------------
|
|
27
28
|
export const TRUST = ['owner', 'agent', 'derived', 'untrusted'];
|
|
28
29
|
export const STATUS = ['active', 'superseded', 'archived'];
|
|
@@ -443,7 +444,7 @@ export function renderPromptBlock(store, now = Date.now()) {
|
|
|
443
444
|
const picked = [];
|
|
444
445
|
let size = 0;
|
|
445
446
|
for (const f of ranked(store, now)) {
|
|
446
|
-
const line = `- ${f.text}`;
|
|
447
|
+
const line = `- ${neutralizeBlockTags(f.text)}`;
|
|
447
448
|
if (picked.length && size + line.length + 1 > PROMPT_CAP)
|
|
448
449
|
break;
|
|
449
450
|
picked.push(line);
|
|
@@ -569,18 +570,22 @@ async function preserveUnvalidatableStore(now) {
|
|
|
569
570
|
/* best-effort */
|
|
570
571
|
}
|
|
571
572
|
}
|
|
573
|
+
// In-process "memory epoch" — bumped after every successful store write. Lets per-session callers
|
|
574
|
+
// (the loop's prompt snapshot) cache a rendered memory block and invalidate it the instant a
|
|
575
|
+
// remember/persona/goal write lands, instead of re-rendering every turn. Re-rendering each turn
|
|
576
|
+
// otherwise busts the model prompt-prefix cache because effImportance() decays with Date.now(), so a
|
|
577
|
+
// byte-identical store still produces a slightly different block on the next turn.
|
|
578
|
+
let memoryEpoch = 0;
|
|
579
|
+
export function memoryStoreEpoch() {
|
|
580
|
+
return memoryEpoch;
|
|
581
|
+
}
|
|
572
582
|
export async function saveStore(store, now = Date.now()) {
|
|
573
583
|
if (!persistenceEnabled())
|
|
574
584
|
return;
|
|
575
585
|
await mkdir(MEMORY_DIR, { recursive: true });
|
|
576
|
-
|
|
577
|
-
if (!firstJson) {
|
|
586
|
+
if (await exists(MEMORY_JSON)) {
|
|
578
587
|
await preserveUnvalidatableStore(now); // data-loss guard before overwriting an unvalidatable store
|
|
579
588
|
}
|
|
580
|
-
else if (await exists(AUTO_MEMORY_FILE)) {
|
|
581
|
-
await copyFile(AUTO_MEMORY_FILE, MEMORY_BAK).catch(() => { });
|
|
582
|
-
await chmod(MEMORY_BAK, 0o600).catch(() => { });
|
|
583
|
-
}
|
|
584
589
|
const tmp = join(MEMORY_DIR, `memory.${randomUUID()}.tmp`);
|
|
585
590
|
try {
|
|
586
591
|
await writeFile(tmp, `${JSON.stringify(store, null, 2)}\n`, { mode: 0o600 });
|
|
@@ -591,5 +596,13 @@ export async function saveStore(store, now = Date.now()) {
|
|
|
591
596
|
await rm(tmp, { force: true }).catch(() => { });
|
|
592
597
|
throw e;
|
|
593
598
|
}
|
|
599
|
+
// drift guard: MEMORY.md is a DERIVED view re-rendered from the store on every write, so a hand-edit
|
|
600
|
+
// (or the legacy pre-json file) would be silently clobbered. Snapshot the current view to MEMORY.md.bak
|
|
601
|
+
// before overwriting, so the previous version is always one `cp` away — on every write, not just the first.
|
|
602
|
+
if (await exists(AUTO_MEMORY_FILE)) {
|
|
603
|
+
await copyFile(AUTO_MEMORY_FILE, MEMORY_BAK).catch(() => { });
|
|
604
|
+
await chmod(MEMORY_BAK, 0o600).catch(() => { });
|
|
605
|
+
}
|
|
594
606
|
await writeSecure(AUTO_MEMORY_FILE, renderView(store, now));
|
|
607
|
+
memoryEpoch++; // invalidate any per-session prompt snapshot built from the prior store
|
|
595
608
|
}
|
package/dist/memory.js
CHANGED
|
@@ -4,8 +4,9 @@ import { buildContextPackBlock, listContextPacks, readContextPackExcerpt, select
|
|
|
4
4
|
import { buildProjectContextBlock, resolveVaultProject } from './project-registry.js';
|
|
5
5
|
import { appHomePath, BRAND, brainTranscriptEnvForced, persistenceEnabled, worklogEnabled } from './brand.js';
|
|
6
6
|
import { redactKey } from './providers/keys.js';
|
|
7
|
+
import { neutralizeBlockTags } from './prompt-safety.js';
|
|
7
8
|
import { loadStore, saveStore, mergeFact, maybeConsolidate, consolidate, renderPromptBlock, activeFacts } from './memory-store.js';
|
|
8
|
-
import { renderPersonaProfile, personaFacts, mergePersonaAnswers, parsePersonaProfileMarkdown, personaAnswersFromFacts } from './persona.js';
|
|
9
|
+
import { renderPersonaProfile, personaFacts, mergePersonaAnswers, parsePersonaProfileMarkdown, personaAnswersFromFacts, renderOwnerPersonaPromptBlock } from './persona.js';
|
|
9
10
|
const MEMORY_FILE = BRAND.memoryFileName;
|
|
10
11
|
// auto-memory (สิ่งที่ agent จำเองข้าม session) ย้ายไปอยู่ใน ./memory-store.ts —
|
|
11
12
|
// memory.json เป็น source of truth, MEMORY.md เป็น view ที่ render จากมัน
|
|
@@ -53,8 +54,10 @@ export async function loadMemory(cwd = process.cwd()) {
|
|
|
53
54
|
seen.add(p);
|
|
54
55
|
try {
|
|
55
56
|
const content = (await readFile(p, 'utf8')).trim();
|
|
57
|
+
// SANOOK.md is untrusted (anyone can drop one in a shared parent dir) — fence so it can't forge
|
|
58
|
+
// a block boundary / role tag and smuggle instructions into the system prompt (§10.4).
|
|
56
59
|
if (content)
|
|
57
|
-
blocks.push(`<memory src="${p}">\n${content}\n</memory>`);
|
|
60
|
+
blocks.push(`<memory src="${p}">\n${neutralizeBlockTags(content)}\n</memory>`);
|
|
58
61
|
}
|
|
59
62
|
catch {
|
|
60
63
|
// ไม่มีไฟล์ = ข้าม
|
|
@@ -75,6 +78,34 @@ export async function loadAutoMemory() {
|
|
|
75
78
|
return '';
|
|
76
79
|
}
|
|
77
80
|
}
|
|
81
|
+
/** Owner persona facts for every agent turn (REPL + Codex delegate). Empty until user runs `sanook persona` or `/persona`. */
|
|
82
|
+
export async function loadOwnerPersonaBlock() {
|
|
83
|
+
try {
|
|
84
|
+
return renderOwnerPersonaPromptBlock(await loadPersonaAnswers());
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return '';
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Context bundle for delegate providers (codex exec) — they skip the SDK system prompt, so we prepend
|
|
92
|
+
* owner persona + auto-memory + project/brain context manually.
|
|
93
|
+
*/
|
|
94
|
+
export async function loadDelegateContext(cwd = process.cwd()) {
|
|
95
|
+
const [persona, autoMemory, brain, memory] = await Promise.all([
|
|
96
|
+
loadOwnerPersonaBlock(),
|
|
97
|
+
loadAutoMemory(),
|
|
98
|
+
loadBrainContext(cwd),
|
|
99
|
+
loadMemory(cwd),
|
|
100
|
+
]);
|
|
101
|
+
const instructions = [
|
|
102
|
+
`You are ${BRAND.agentName}.`,
|
|
103
|
+
'Durable owner facts live in <owner_persona> and <auto_memory> below.',
|
|
104
|
+
'When the user asks what you remember about them, their name, or preferences, answer from those blocks — do not claim you have no cross-session memory if facts are present.',
|
|
105
|
+
`If both blocks are empty, say you have not learned their profile yet and suggest \`${BRAND.cliName} persona\` or \`/persona\` in the REPL.`,
|
|
106
|
+
].join(' ');
|
|
107
|
+
return [instructions, persona, autoMemory, brain, memory].filter(Boolean).join('\n\n');
|
|
108
|
+
}
|
|
78
109
|
/**
|
|
79
110
|
* โหลด context ของ second-brain vault ที่ user scaffold ไว้ (sanook brain) — ทำให้ agent
|
|
80
111
|
* "รู้จัก" vault: inject Shared/AI-Context-Index.md (ไฟล์ที่ vault บอกให้อ่านก่อน) เข้า system prompt
|
|
@@ -148,7 +179,10 @@ export function renderBrainContext(brainPath, parts) {
|
|
|
148
179
|
const content = parts.map((part) => part.content).filter(Boolean);
|
|
149
180
|
if (!content.length)
|
|
150
181
|
return '';
|
|
151
|
-
|
|
182
|
+
// vault files are user/ingested content (clippings, intake, pasted notes) = untrusted data, not
|
|
183
|
+
// instructions (§10.4). Fence so a note containing `</brain_vault>` or `<system>…` can't break out.
|
|
184
|
+
const fenced = neutralizeBlockTags(content.join('\n\n'));
|
|
185
|
+
return `<brain_vault path="${brainPath}" note="second-brain ของ user — สิ่งที่จำไว้/state ปัจจุบันอยู่ใน block นี้; route โน้ตตาม Vault Structure Map; อ่าน/เขียนไฟล์ใน vault ด้วย absolute path ได้">\n${fenced}\n</brain_vault>`;
|
|
152
186
|
}
|
|
153
187
|
/** ประกอบ brain context จาก vault path (pure → testable) — entry + current-state + remembered facts + optional context pack */
|
|
154
188
|
export async function buildBrainContext(brainPath, options = {}) {
|
|
@@ -430,7 +464,7 @@ export async function appendMemory(fact, noteType) {
|
|
|
430
464
|
* เขียน persona/identity ที่เก็บตอน setup (ขั้นที่ 9) ลง durable auto-memory เป็น owner ground-truth
|
|
431
465
|
* (tier protected, trust owner) → หลัง setup เสร็จ agent "จำ" ว่าเจ้าของชื่ออะไร / เรียก AI ว่าอะไร /
|
|
432
466
|
* ภาษา + autonomy ทันที โดยไม่ต้องรอ remember. ใช้คู่กับ scaffoldBrain ที่ substitute ลงไฟล์ vault อยู่แล้ว.
|
|
433
|
-
* idempotent: เขียนซ้ำ = NOOP (mergeFact).
|
|
467
|
+
* idempotent: เขียนซ้ำ = NOOP (mergeFact). ข้ามค่าว่าง (raw typed '') เพื่อไม่ปน noise.
|
|
434
468
|
*/
|
|
435
469
|
export async function seedPersonaMemory(input) {
|
|
436
470
|
if (!persistenceEnabled())
|
|
@@ -440,9 +474,14 @@ export async function seedPersonaMemory(input) {
|
|
|
440
474
|
const ai = input.aiName?.trim();
|
|
441
475
|
const lang = input.language?.trim();
|
|
442
476
|
const autonomy = input.autonomy?.trim();
|
|
443
|
-
|
|
477
|
+
// Gate on "did the user actually type a value" (non-empty) — NOT on equality with a default
|
|
478
|
+
// sentinel. The old `owner !== defaults.ownerName` check silently DROPPED a real name that happened
|
|
479
|
+
// to equal the placeholder ('Owner'), and forced callers to coerce blank→default first (so a skipped
|
|
480
|
+
// name became 'Owner' and was then dropped). Callers now pass the RAW typed value ('' when skipped);
|
|
481
|
+
// an empty string simply isn't seeded, any non-empty value always is.
|
|
482
|
+
if (owner)
|
|
444
483
|
facts.push({ text: `เจ้าของชื่อ ${owner} — เรียกเจ้าของด้วยชื่อนี้`, noteType: 'entity', trust: 'owner', tier: 'protected' });
|
|
445
|
-
if (ai
|
|
484
|
+
if (ai)
|
|
446
485
|
facts.push({ text: `AI เรียกตัวเองว่า "${ai}" เมื่อคุยกับเจ้าของ`, noteType: 'preference', trust: 'owner', tier: 'protected' });
|
|
447
486
|
if (lang)
|
|
448
487
|
facts.push({ text: `ภาษาที่เจ้าของต้องการให้ตอบ: ${lang}`, noteType: 'preference', trust: 'owner', tier: 'protected' });
|
|
@@ -551,3 +590,15 @@ export async function persistPersonaAnswers(answers) {
|
|
|
551
590
|
const vaultWritten = brain ? await writePersonaProfile(brain, answers).catch(() => false) : false;
|
|
552
591
|
return { memoryWritten, vaultWritten, brainPath: brain };
|
|
553
592
|
}
|
|
593
|
+
/**
|
|
594
|
+
* Persist a PARTIAL persona update (e.g. `/goal <text>` sets only `goals`) without clobbering the rest.
|
|
595
|
+
* `persistPersonaAnswers` rewrites the vault persona.md table from exactly the answers it's given, so
|
|
596
|
+
* passing only `{ goals }` would blank every other field. Here we load the existing answers first
|
|
597
|
+
* (memory + vault), merge the patch on top, then persist the full merged set — so a single-field update
|
|
598
|
+
* keeps name/language/tone/etc. intact in both auto-memory and the vault profile.
|
|
599
|
+
*/
|
|
600
|
+
export async function persistPersonaPatch(patch) {
|
|
601
|
+
const existing = await loadPersonaAnswers().catch(() => ({}));
|
|
602
|
+
const merged = mergePersonaAnswers(existing, patch);
|
|
603
|
+
return persistPersonaAnswers(merged);
|
|
604
|
+
}
|
package/dist/persona.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// Pure / no-UI so it stays unit-testable; the Ink wizard (src/ui/persona-wizard.tsx)
|
|
3
3
|
// renders PERSONA_QUESTIONS and the persist layer (src/memory.ts) uses personaFacts /
|
|
4
4
|
// renderPersonaProfile to write to auto-memory + the second-brain vault.
|
|
5
|
+
import { neutralizeBlockTags } from './prompt-safety.js';
|
|
5
6
|
/** sentinel value: a select option that drops into a free-text follow-up */
|
|
6
7
|
export const PERSONA_OTHER = '__other__';
|
|
7
8
|
const otherOption = { label: 'อื่นๆ (พิมพ์เอง)', value: PERSONA_OTHER };
|
|
@@ -173,6 +174,15 @@ export function personaFacts(answers) {
|
|
|
173
174
|
}
|
|
174
175
|
return out;
|
|
175
176
|
}
|
|
177
|
+
/** Compact owner block for system/delegate prompts — facts only, no markdown table noise. */
|
|
178
|
+
export function renderOwnerPersonaPromptBlock(answers) {
|
|
179
|
+
const facts = personaFacts(answers);
|
|
180
|
+
if (!facts.length)
|
|
181
|
+
return '';
|
|
182
|
+
// fence each fact: persona answers are user-typed and could contain a forged block boundary
|
|
183
|
+
const fenced = facts.map((f) => `- ${neutralizeBlockTags(f)}`).join('\n');
|
|
184
|
+
return `<owner_persona note="โปรไฟล์เจ้าของจาก sanook persona — ground truth สำหรับชื่อ/บทบาท/ความชอบ; เมื่อ user ถามว่าจำได้ไหม/ชื่ออะไร ให้ตอบจาก block นี้และ auto_memory">\n${fenced}\n</owner_persona>`;
|
|
185
|
+
}
|
|
176
186
|
/** human-friendly label for a stored select value (falls back to the raw value / free text). */
|
|
177
187
|
function answerLabel(q, value) {
|
|
178
188
|
if (q.type === 'select' && q.options) {
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Prompt-injection fencing for memory/persona content that gets wrapped in XML-ish blocks
|
|
2
|
+
// (<auto_memory>, <owner_persona>, <brain_vault>, …) inside the system prompt. A remembered fact or
|
|
3
|
+
// vault line is untrusted data — if it contains a literal `</auto_memory>` (or a fake `<system>` /
|
|
4
|
+
// role tag) it could forge the end of its block and smuggle instructions into the prompt. We break any
|
|
5
|
+
// such boundary token by inserting a zero-width space after the `<`, so the rendered text looks
|
|
6
|
+
// identical to a human but no longer parses as a real tag.
|
|
7
|
+
const BLOCK_TAGS = /<\/?\s*(auto_memory|owner_persona|brain_vault|memory|system|user|assistant|tool_result|tool_call)\b/gi;
|
|
8
|
+
const ZW = ''; // zero-width space — visually invisible, breaks the `<tag` token
|
|
9
|
+
/** Neutralize block-boundary / role tags embedded in untrusted memory text. Idempotent-ish and safe on
|
|
10
|
+
* normal prose (only touches the specific tag tokens above). */
|
|
11
|
+
export function neutralizeBlockTags(text) {
|
|
12
|
+
return text.replace(BLOCK_TAGS, (m) => `<${ZW}${m.slice(1)}`);
|
|
13
|
+
}
|
package/dist/prompt-size.js
CHANGED
|
@@ -2,7 +2,7 @@ import { BRAND } from './brand.js';
|
|
|
2
2
|
import { loadConfig } from './config.js';
|
|
3
3
|
import { gitContext } from './git.js';
|
|
4
4
|
import { SYSTEM } from './loop.js';
|
|
5
|
-
import { loadAutoMemory, loadBrainContext, loadMemory } from './memory.js';
|
|
5
|
+
import { loadAutoMemory, loadBrainContext, loadMemory, loadOwnerPersonaBlock } from './memory.js';
|
|
6
6
|
import { personalityPrompt } from './personality.js';
|
|
7
7
|
import { loadRepoMap } from './repomap.js';
|
|
8
8
|
import { loadSkills, renderAvailableSkills } from './skills.js';
|
|
@@ -76,10 +76,11 @@ export function serializeToolSchemas(tools) {
|
|
|
76
76
|
export async function buildPromptSizeBreakdown(options = {}) {
|
|
77
77
|
const cwd = options.cwd ?? process.cwd();
|
|
78
78
|
const planMode = options.planMode ?? false;
|
|
79
|
-
const [config, memory, autoMemory, skills, git, brain, repoMap,] = await Promise.all([
|
|
79
|
+
const [config, memory, autoMemory, ownerPersona, skills, git, brain, repoMap,] = await Promise.all([
|
|
80
80
|
(options.loadConfigImpl ?? loadConfig)({}, cwd),
|
|
81
81
|
(options.loadMemoryImpl ?? loadMemory)(cwd),
|
|
82
82
|
(options.loadAutoMemoryImpl ?? loadAutoMemory)(),
|
|
83
|
+
(options.loadOwnerPersonaBlockImpl ?? loadOwnerPersonaBlock)(),
|
|
83
84
|
(options.loadSkillsImpl ?? loadSkills)(cwd),
|
|
84
85
|
(options.gitContextImpl ?? gitContext)(cwd),
|
|
85
86
|
(options.loadBrainContextImpl ?? loadBrainContext)(),
|
|
@@ -94,12 +95,13 @@ export async function buildPromptSizeBreakdown(options = {}) {
|
|
|
94
95
|
const baseSystem = SYSTEM + planSuffix + brainNudge;
|
|
95
96
|
const personality = personalityPrompt(config.personality);
|
|
96
97
|
const skillsBlock = renderAvailableSkills(skills);
|
|
97
|
-
const staticSystem = joinPromptBlocks([baseSystem, personality, autoMemory, skillsBlock, brain, memory, repoMap]);
|
|
98
|
+
const staticSystem = joinPromptBlocks([baseSystem, personality, ownerPersona, autoMemory, skillsBlock, brain, memory, repoMap]);
|
|
98
99
|
const systemPromptText = joinPromptBlocks([staticSystem, git]);
|
|
99
100
|
const toolSchemaText = serializeToolSchemas(options.tools ?? builtInTools);
|
|
100
101
|
const sections = [
|
|
101
102
|
measurePromptSection('base-system', 'Base system', baseSystem),
|
|
102
103
|
measurePromptSection('personality', 'Personality overlay', personality),
|
|
104
|
+
measurePromptSection('owner-persona', 'Owner persona', ownerPersona),
|
|
103
105
|
measurePromptSection('auto-memory', 'Auto memory', autoMemory),
|
|
104
106
|
measurePromptSection('skills-index', 'Skills index', skillsBlock),
|
|
105
107
|
measurePromptSection('brain-context', 'Second-brain context', brain),
|
package/dist/ui/app.js
CHANGED
|
@@ -37,7 +37,7 @@ import { MarkdownText, StreamingMarkdownText } from './markdown.js';
|
|
|
37
37
|
import { SessionPanel } from './session-panel.js';
|
|
38
38
|
import { getTranscriptWindow, transcriptScrollStep, transcriptWindowSize } from './transcript.js';
|
|
39
39
|
import { footerStatus } from './status.js';
|
|
40
|
-
import { inputViewport,
|
|
40
|
+
import { inputViewport, formatInputLineDisplay, formatMultilineInputDisplay } from './input-view.js';
|
|
41
41
|
import { PersonaOverlay } from './persona-wizard.js';
|
|
42
42
|
import { thinkingPanelLines, snapshotThinking } from './thinking-panel.js';
|
|
43
43
|
import { toolTrailLines, toolTrailHeader, toolTrailWidth, updateToolTrailOnEvent } from './tool-trail.js';
|
|
@@ -974,6 +974,15 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
|
|
|
974
974
|
setPersonaOpen(true);
|
|
975
975
|
return;
|
|
976
976
|
}
|
|
977
|
+
if (cmd.action === 'goalSet') {
|
|
978
|
+
const goalText = cmd.goalText ?? '';
|
|
979
|
+
void (async () => {
|
|
980
|
+
const { persistPersonaPatch } = await import('../memory.js');
|
|
981
|
+
await persistPersonaPatch({ goals: goalText });
|
|
982
|
+
addTurn('system', cmd.message ?? 'บันทึกเป้าหมายแล้ว');
|
|
983
|
+
})().catch((e) => addTurn('system', `goal: ${e.message}`));
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
977
986
|
if (cmd.action === 'insights') {
|
|
978
987
|
void renderInsights({ days: cmd.insightsDays, cwd: cmd.insightsAll ? null : undefined })
|
|
979
988
|
.then((msg) => addTurn('system', msg))
|
|
@@ -1246,20 +1255,16 @@ function InputView({ value, cursor, busy, agentStatus, toolTrail, columns = 80,
|
|
|
1246
1255
|
if (!busy && !value) {
|
|
1247
1256
|
return (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: "\u0E16\u0E32\u0E21\u0E2D\u0E30\u0E44\u0E23\u0E01\u0E47\u0E44\u0E14\u0E49 \u2014 /help \u0E14\u0E39\u0E04\u0E33\u0E2A\u0E31\u0E48\u0E07 \u00B7 /tools \u0E14\u0E39 tools \u00B7 @\u0E44\u0E1F\u0E25\u0E4C \u0E41\u0E19\u0E1A context/\u0E23\u0E39\u0E1B" }));
|
|
1248
1257
|
}
|
|
1249
|
-
// multiline (กด Alt+Enter / ลงท้าย \) —
|
|
1258
|
+
// multiline (กด Alt+Enter / ลงท้าย \) — สูงหลายบรรทัดตั้งใจอยู่แล้ว
|
|
1250
1259
|
if (value.includes('\n')) {
|
|
1251
|
-
|
|
1252
|
-
const graphemes = graphemesOf(value);
|
|
1253
|
-
const before = graphemes.slice(0, insertAt).join('');
|
|
1254
|
-
const after = graphemes.slice(insertAt).join('');
|
|
1255
|
-
return (_jsxs(Text, { children: [before, _jsx(Text, { inverse: true, children: ' ' }), after, busy ? _jsxs(Text, { dimColor: true, children: [' ', "(\u23CE \u0E15\u0E48\u0E2D\u0E04\u0E34\u0E27)"] }) : null] }));
|
|
1260
|
+
return (_jsx(Text, { wrap: "truncate-end", children: formatMultilineInputDisplay(value, cursor, { queueHint: busy ? ' (⏎ ต่อคิว)' : undefined }) }));
|
|
1256
1261
|
}
|
|
1257
1262
|
// บรรทัดเดียว: viewport กว้างคงที่ (เลื่อนแนวนอนแทน wrap) → กล่อง input สูง 1 บรรทัดเสมอ ไม่เด้งตอนพิมพ์ไทย
|
|
1258
1263
|
// เผื่อ overhead: border(2) + paddingX(2) + prefix "› "(2) + ช่อง cursor/suffix ~2
|
|
1259
1264
|
const queueHint = busy ? ' (⏎ ต่อคิว)' : '';
|
|
1260
1265
|
const reserved = 8 + queueHint.length;
|
|
1261
1266
|
const vp = inputViewport(value, cursor, Math.max(8, columns - reserved));
|
|
1262
|
-
return (
|
|
1267
|
+
return _jsx(Text, { wrap: "truncate-end", children: formatInputLineDisplay(vp, { queueHint: queueHint || undefined }) });
|
|
1263
1268
|
}
|
|
1264
1269
|
function statusColor(status) {
|
|
1265
1270
|
return status === 'error' ? 'red' : status === 'running' ? 'yellow' : 'green';
|
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,3 +1,4 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
1
2
|
import stringWidth from 'string-width';
|
|
2
3
|
import { clampCursorToGrapheme, graphemeBoundaries } from './useEditor.js';
|
|
3
4
|
// ────────────────────────────────────────────────────────────────────────────
|
|
@@ -5,9 +6,9 @@ import { clampCursorToGrapheme, graphemeBoundaries } from './useEditor.js';
|
|
|
5
6
|
// Regression guards: repl-layout-guard.test.ts + input-view.test.ts (width + gap cursor).
|
|
6
7
|
//
|
|
7
8
|
// Two bugs this fixes (เทียบกับ CLI เจ้าอื่นที่ "นิ่ง"):
|
|
8
|
-
// 1) block cursor on Thai text —
|
|
9
|
-
//
|
|
10
|
-
// Fix:
|
|
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
|
|
@@ -107,3 +108,26 @@ export function inputViewport(value, cursor, width) {
|
|
|
107
108
|
tail: end < units.length,
|
|
108
109
|
};
|
|
109
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;
|
|
@@ -34,10 +35,10 @@ function OverlayBox({ children, columns }) {
|
|
|
34
35
|
function overlayWidth(columns) {
|
|
35
36
|
return Math.max(34, Math.min(Math.max(MIN_OVERLAY_COLUMNS, Math.floor(columns || 80) - 4), MAX_OVERLAY_COLUMNS));
|
|
36
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.
|
|
37
40
|
function clip(text, width) {
|
|
38
|
-
|
|
39
|
-
return '';
|
|
40
|
-
return text.length > width ? `${text.slice(0, Math.max(0, width - 1))}…` : text;
|
|
41
|
+
return clipToWidth(text, width);
|
|
41
42
|
}
|
|
42
43
|
export function completionOverlayLines(items, selected, columns) {
|
|
43
44
|
if (!items.length)
|
|
@@ -52,7 +53,7 @@ export function completionOverlayLines(items, selected, columns) {
|
|
|
52
53
|
const lines = visible.map((item, offset) => {
|
|
53
54
|
const index = start + offset;
|
|
54
55
|
const cursor = index === safeSelected ? '>' : ' ';
|
|
55
|
-
return `${cursor} ${clip(item.display, commandWidth)
|
|
56
|
+
return `${cursor} ${padEndToWidth(clip(item.display, commandWidth), commandWidth)} ${clip(item.meta, metaWidth)}`;
|
|
56
57
|
});
|
|
57
58
|
lines.push('↑↓ select · Tab/Enter complete');
|
|
58
59
|
return lines;
|
|
@@ -113,7 +114,7 @@ export function modelOverlayLines(overlay, columns) {
|
|
|
113
114
|
for (const [offset, provider] of visible.entries()) {
|
|
114
115
|
const index = window.start + offset;
|
|
115
116
|
const cursor = index === overlay.selected ? '>' : ' ';
|
|
116
|
-
lines.push(`${cursor} ${clip(provider.label, nameWidth)
|
|
117
|
+
lines.push(`${cursor} ${padEndToWidth(clip(provider.label, nameWidth), nameWidth)} ${provider.modelCount} models · ${provider.status}`);
|
|
117
118
|
}
|
|
118
119
|
if (window.end < overlay.providers.length)
|
|
119
120
|
lines.push(`... ${overlay.providers.length - window.end} more`);
|
|
@@ -132,7 +133,7 @@ export function modelOverlayLines(overlay, columns) {
|
|
|
132
133
|
const index = window.start + offset;
|
|
133
134
|
const cursor = index === overlay.selected ? '>' : ' ';
|
|
134
135
|
const current = option.current ? '*' : ' ';
|
|
135
|
-
lines.push(`${cursor}${current} ${clip(option.label, optionWidth)
|
|
136
|
+
lines.push(`${cursor}${current} ${padEndToWidth(clip(option.label, optionWidth), optionWidth)} ${clip(option.meta, metaWidth)}`);
|
|
136
137
|
}
|
|
137
138
|
if (window.end < overlay.options.length)
|
|
138
139
|
lines.push(`... ${overlay.options.length - window.end} more`);
|
|
@@ -186,7 +187,7 @@ export function mcpOverlayLines(overlay, columns) {
|
|
|
186
187
|
for (const [offset, server] of visible.entries()) {
|
|
187
188
|
const index = window.start + offset;
|
|
188
189
|
const cursor = index === overlay.selected ? '>' : ' ';
|
|
189
|
-
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)}`);
|
|
190
191
|
}
|
|
191
192
|
if (window.end < overlay.servers.length)
|
|
192
193
|
lines.push(`... ${overlay.servers.length - window.end} more`);
|
|
@@ -277,7 +278,7 @@ export function skillsOverlayLines(overlay, columns) {
|
|
|
277
278
|
for (const [offset, skill] of visible.entries()) {
|
|
278
279
|
const index = window.start + offset;
|
|
279
280
|
const cursor = index === overlay.selected ? '>' : ' ';
|
|
280
|
-
lines.push(`${cursor} ${clip(skill.name, nameWidth)
|
|
281
|
+
lines.push(`${cursor} ${padEndToWidth(clip(skill.name, nameWidth), nameWidth)} ${clip(skill.description || '(no description)', descWidth)}`);
|
|
281
282
|
}
|
|
282
283
|
if (window.end < overlay.skills.length)
|
|
283
284
|
lines.push(`... ${overlay.skills.length - window.end} more`);
|
|
@@ -321,7 +322,7 @@ export function toolsOverlayLines(overlay, columns) {
|
|
|
321
322
|
for (const [offset, tool] of visible.entries()) {
|
|
322
323
|
const index = window.start + offset;
|
|
323
324
|
const cursor = index === overlay.selected ? '>' : ' ';
|
|
324
|
-
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)}`);
|
|
325
326
|
}
|
|
326
327
|
if (window.end < overlay.tools.length)
|
|
327
328
|
lines.push(`... ${overlay.tools.length - window.end} more`);
|
|
@@ -469,7 +470,7 @@ export function sessionsOverlayLines(overlay, columns) {
|
|
|
469
470
|
const cursor = index === overlay.selected ? '>' : ' ';
|
|
470
471
|
const title = sessionTitle(session, overlay.currentCwd);
|
|
471
472
|
const meta = `${session.model} · ${shortDate(session.updated)}`;
|
|
472
|
-
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)}`);
|
|
473
474
|
}
|
|
474
475
|
if (window.end < overlay.sessions.length)
|
|
475
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
|
+
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
import { clipToWidth } from './text-width.js';
|
|
1
2
|
const THINKING_CHAR_LIMIT = 2_000;
|
|
2
3
|
const THINKING_LINE_LIMIT = 6;
|
|
4
|
+
// display-width aware: the panel body is the model's Thai reasoning — .length-based clipping under-fills
|
|
5
|
+
// the line and can split a cluster (orphaned tone mark)
|
|
3
6
|
function clip(text, width) {
|
|
4
|
-
|
|
5
|
-
return '';
|
|
6
|
-
return text.length > width ? `${text.slice(0, Math.max(0, width - 3))}...` : text;
|
|
7
|
+
return clipToWidth(text, width, '...');
|
|
7
8
|
}
|
|
8
9
|
function normalize(text) {
|
|
9
10
|
return text.replace(/\s+/g, ' ').trim();
|
package/dist/ui/tool-trail.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { inspect } from 'node:util';
|
|
2
2
|
import { describeToolCall } from './tool-activity.js';
|
|
3
|
+
import { clipToWidth, padEndToWidth } from './text-width.js';
|
|
3
4
|
export const TOOL_TRAIL_LIMIT = 6;
|
|
5
|
+
// display-width aware (Thai filenames / emoji activity titles) so the trail columns stay aligned
|
|
4
6
|
function clip(text, width) {
|
|
5
|
-
|
|
6
|
-
return '';
|
|
7
|
-
return text.length > width ? `${text.slice(0, Math.max(0, width - 3))}...` : text;
|
|
7
|
+
return clipToWidth(text, width, '...');
|
|
8
8
|
}
|
|
9
9
|
function normalizeWhitespace(text) {
|
|
10
10
|
return text.replace(/\s+/g, ' ').trim();
|
|
@@ -103,7 +103,7 @@ export function toolTrailLines(items, columns, mode = 'expanded') {
|
|
|
103
103
|
for (const item of items) {
|
|
104
104
|
const marker = markerForStatus(item.status);
|
|
105
105
|
const detail = item.detail ? ` ${clip(item.detail, detailWidth)}` : '';
|
|
106
|
-
lines.push(`${marker} ${clip(item.name, nameWidth)
|
|
106
|
+
lines.push(`${marker} ${padEndToWidth(clip(item.name, nameWidth), nameWidth)} ${item.status.padEnd(7)}${detail}`);
|
|
107
107
|
}
|
|
108
108
|
return lines.map((line) => clip(line, width));
|
|
109
109
|
}
|
package/package.json
CHANGED