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 CHANGED
@@ -1,5 +1,28 @@
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
+
17
+ ## 0.5.11
18
+
19
+ ### REPL layout stability
20
+
21
+ - **Transcript** — completed chat turns render in Ink `<Static>` so typing no longer redraws the whole history (input dock stays put).
22
+ - **Input** — Thai-safe gap cursor + single-row horizontal viewport (no 1↔2 line bounce).
23
+ - **Completions** — fixed-height slot for `/` and `@` completion overlay so the prompt does not jump when suggestions appear.
24
+ - **Regression guards** — source + behavior tests in `repl-layout-guard.test.ts`, `repl-input.test.tsx`, `overlay.test.tsx`.
25
+
3
26
  ## 0.5.10
4
27
 
5
28
  - Patch release (npm republish guard — use this version after 0.5.9 is already on the registry).
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: '10. Ready',
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: '10. พร้อมใช้งาน',
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
- // codex exec ไม่เห็น conversation history เอง → prepend transcript ให้มี context ข้าม turn
157
- // (ไม่งั้น REPL ทุก turn = contextless, codex ลืมที่คุยมาทั้งหมด)
158
- const prior = (opts.history ?? [])
159
- .map((m) => {
160
- const role = m.role === 'user' ? 'User' : m.role === 'assistant' ? 'Assistant' : '';
161
- if (!role)
162
- return '';
163
- const c = typeof m.content === 'string'
164
- ? m.content
165
- : Array.isArray(m.content)
166
- ? m.content.map((p) => (typeof p === 'object' && p && 'type' in p && p.type === 'text' ? p.text : '')).join('')
167
- : '';
168
- return c.trim() ? `${role}: ${c.trim()}` : '';
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
- const [memory, autoMemory, skills, git, brain, repoMap, tuning, config] = await Promise.all([
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,
@@ -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
- const firstJson = !(await exists(MEMORY_JSON));
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
- return `<brain_vault path="${brainPath}" note="second-brain ของ user สิ่งที่จำไว้/state ปัจจุบันอยู่ใน block นี้; route โน้ตตาม Vault Structure Map; อ่าน/เขียนไฟล์ใน vault ด้วย absolute path ได้">\n${content.join('\n\n')}\n</brain_vault>`;
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). ข้ามค่า default/ว่าง เพื่อไม่ปน noise.
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
- if (owner && owner !== input.defaults?.ownerName)
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 && ai !== input.defaults?.aiName)
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
+ }
@@ -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
@@ -2,7 +2,7 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
2
2
  import { useEffect, useState, useRef } from 'react';
3
3
  import { execFile } from 'node:child_process';
4
4
  import { promisify } from 'node:util';
5
- import { Box, Text, useApp, useInput, useStdout } from 'ink';
5
+ import { Box, Static, Text, useApp, useInput, useStdout } from 'ink';
6
6
  import { homedir } from 'node:os';
7
7
  import { BUILTIN_COMMANDS, HELP_TEXT, expandCustomCommand, loadCustomCommands, parseCommand, parseSlashInvocation, } from '../commands.js';
8
8
  import { runAgent } from '../loop.js';
@@ -31,13 +31,13 @@ import { expandMentions } from './mentions.js';
31
31
  import { BRAND } from '../brand.js';
32
32
  import { backgroundTaskRunningCount, listBackgroundTasks } from '../tools/task.js';
33
33
  import { Banner } from './banner.js';
34
- import { CompletionOverlay, FloatingOverlay, firstUserSummary } from './overlay.js';
34
+ import { CompletionOverlay, FloatingOverlay, firstUserSummary, shouldReserveCompletionSlot } from './overlay.js';
35
35
  import { clampQueueActiveIndex, compactPreview, getQueueWindow, queueActiveIndexAfterDelete } from './queue.js';
36
36
  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, graphemesOf, cursorGraphemeIndex, SCROLL_LEAD, SCROLL_TAIL } from './input-view.js';
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';
@@ -176,6 +176,9 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
176
176
  };
177
177
  const addTurn = (role, text, extras) => {
178
178
  setTranscriptScroll(0);
179
+ // Remount frozen transcript when history grows so scrollback styling (e.g. latest-only expanded
180
+ // tool diffs) stays correct. Keystrokes never call addTurn, so the input dock stays stable while typing.
181
+ setHistoryResetKey((key) => key + 1);
179
182
  setHistory((h) => [
180
183
  ...h,
181
184
  {
@@ -212,6 +215,7 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
212
215
  const pagerPageSize = Math.max(5, Math.min(18, (stdout?.rows ?? 24) - 10));
213
216
  const completion = !overlay && !busy ? completionForInput(editor.value, cwd) : { items: [], replaceFrom: 0 };
214
217
  const completions = completion.items;
218
+ const reserveCompletionSlot = shouldReserveCompletionSlot(editor.value, completions);
215
219
  const selectedCompletion = clampCompletionIndex(completionIndex, completions.length);
216
220
  useEffect(() => {
217
221
  let alive = true;
@@ -970,6 +974,15 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
970
974
  setPersonaOpen(true);
971
975
  return;
972
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
+ }
973
986
  if (cmd.action === 'insights') {
974
987
  void renderInsights({ days: cmd.insightsDays, cwd: cmd.insightsAll ? null : undefined })
975
988
  .then((msg) => addTurn('system', msg))
@@ -1208,20 +1221,25 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
1208
1221
  const transcriptLimit = transcriptWindowSize(stdout?.rows);
1209
1222
  const transcriptView = getTranscriptWindow(history.length, transcriptLimit, transcriptScroll);
1210
1223
  const visibleHistory = history.slice(transcriptView.start, transcriptView.end);
1211
- return (_jsxs(Box, { flexDirection: "column", children: [history.length === 0 ? (_jsxs(_Fragment, { children: [_jsx(Banner, { columns: columns, model: model, mode: permissionMode === 'ask' ? 'ask' : 'auto', signals: bannerSignals }), _jsx(SessionPanel, { columns: columns, cwd: cwd, mcp: startupReadiness.mcp, model: model, mode: permissionMode === 'ask' ? 'ask' : 'auto', skills: startupReadiness.skills })] })) : null, transcriptView.showOlder ? (_jsxs(Text, { dimColor: true, children: ["\u2026 ", transcriptView.start, " older turns \u00B7 PgUp/Ctrl+U scroll \u00B7 PgDn/Ctrl+D newer"] })) : null, visibleHistory.map((turn) => (_jsx(TurnView, { columns: columns, isLatest: turn.id === latestTrailTurnId, thinkingMode: thinkingMode, toolTrailMode: toolTrailMode, turn: turn }, `${historyResetKey}-${turn.id}`))), transcriptView.showNewer ? (_jsxs(Text, { dimColor: true, children: ["\u2026 ", transcriptView.scrollFromBottom, " newer turns hidden \u00B7 PgDn/Ctrl+D to catch up"] })) : null, thinkingView.length ? _jsx(ThinkingView, { columns: columns, mode: thinkingMode, text: thinking }) : null, streaming ? (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(StreamingMarkdownText, { columns: columns, text: streaming }) })) : null, showToolTrail ? (_jsx(ToolTrailView, { columns: columns, items: toolTrail, mode: toolTrailMode })) : null, _jsx(FloatingOverlay, { columns: columns, overlay: overlay, pageSize: pagerPageSize }), _jsx(CompletionOverlay, { columns: columns, items: completions, selected: selectedCompletion }), queued.length ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { dimColor: true, children: ["queued (", queued.length, ") \u00B7 \u2191\u2193 select \u00B7 Ctrl+X delete \u00B7 Esc clears"] }), queueWindow.showLead ? _jsx(Text, { dimColor: true, children: " \u2026" }) : null, queued.slice(queueWindow.start, queueWindow.end).map((q, i) => (_jsxs(Text, { color: queueWindow.start + i === activeQueueIndex ? 'yellow' : undefined, dimColor: queueWindow.start + i !== activeQueueIndex, children: [queueWindow.start + i === activeQueueIndex ? '›' : ' ', " ", queueWindow.start + i + 1, ".", ' ', compactPreview(q, Math.max(16, columns - 10))] }, `${queueWindow.start + i}-${q.slice(0, 16)}`))), queueWindow.showTail ? _jsxs(Text, { dimColor: true, children: [" \u2026and ", queued.length - queueWindow.end, " more"] }) : null] })) : null, approvalReq ? (_jsxs(Box, { marginTop: 1, borderStyle: "round", borderColor: "yellow", paddingX: 1, flexDirection: "column", children: [_jsxs(Text, { color: "yellow", children: ["\u0E2D\u0E19\u0E38\u0E21\u0E31\u0E15\u0E34\u0E23\u0E31\u0E19 ", approvalReq.tool, "?"] }), _jsx(Text, { dimColor: true, children: approvalReq.summary }), _jsx(Text, { dimColor: true, children: "y = \u0E23\u0E31\u0E19 \u00B7 n = \u0E1B\u0E0F\u0E34\u0E40\u0E2A\u0E18" })] })) : personaOpen ? (_jsx(PersonaOverlay, { onDone: (msg) => { setPersonaOpen(false); addTurn('system', msg); } })) : (_jsxs(Box, { marginTop: 1, borderStyle: "round", borderColor: busy ? 'gray' : 'blue', paddingX: 1, children: [_jsx(Text, { color: busy ? 'gray' : 'cyan', children: busy ? '· ' : '› ' }), _jsx(InputView, { value: editor.value, cursor: editor.cursor, busy: busy, agentStatus: agentStatus, toolTrail: toolTrail, columns: columns })] })), _jsx(Text, { dimColor: true, wrap: "truncate-end", children: footerStatus({
1212
- branch: gitBranch,
1213
- backgroundTaskCount: bgTaskCount,
1214
- busy,
1215
- columns,
1216
- contextCompression,
1217
- contextTokens,
1218
- costHint,
1219
- cwd,
1220
- elapsedSeconds: busyElapsedSeconds,
1221
- model,
1222
- mode: permissionMode === 'ask' ? 'ask' : 'auto',
1223
- queuedCount: queued.length,
1224
- }) })] }));
1224
+ // REPL layout invariants see repl-layout-guard.test.ts (regression guards if this jumps while typing).
1225
+ // Pinned to latest: freeze completed turns in Ink <Static> so keystrokes only redraw input/footer
1226
+ // (readline / Claude Code pattern). When scrolled up, fall back to a windowed dynamic transcript.
1227
+ const pinnedToBottom = transcriptScroll === 0;
1228
+ const renderTurn = (turn) => (_jsx(TurnView, { columns: columns, isLatest: turn.id === latestTrailTurnId, thinkingMode: thinkingMode, toolTrailMode: toolTrailMode, turn: turn }, turn.id));
1229
+ return (_jsxs(Box, { flexDirection: "column", children: [history.length === 0 ? (_jsxs(_Fragment, { children: [_jsx(Banner, { columns: columns, model: model, mode: permissionMode === 'ask' ? 'ask' : 'auto', signals: bannerSignals }), _jsx(SessionPanel, { columns: columns, cwd: cwd, mcp: startupReadiness.mcp, model: model, mode: permissionMode === 'ask' ? 'ask' : 'auto', skills: startupReadiness.skills })] })) : null, pinnedToBottom ? (history.length ? (_jsx(Static, { items: history, children: (turn) => renderTurn(turn) }, historyResetKey)) : null) : (_jsxs(_Fragment, { children: [transcriptView.showOlder ? (_jsxs(Text, { dimColor: true, children: ["\u2026 ", transcriptView.start, " older turns \u00B7 PgUp/Ctrl+U scroll \u00B7 PgDn/Ctrl+D newer"] })) : null, visibleHistory.map((turn) => renderTurn(turn)), transcriptView.showNewer ? (_jsxs(Text, { dimColor: true, children: ["\u2026 ", transcriptView.scrollFromBottom, " newer turns hidden \u00B7 PgDn/Ctrl+D to catch up"] })) : null] })), _jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [thinkingView.length ? _jsx(ThinkingView, { columns: columns, mode: thinkingMode, text: thinking }) : null, streaming ? (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(StreamingMarkdownText, { columns: columns, text: streaming }) })) : null, showToolTrail ? (_jsx(ToolTrailView, { columns: columns, items: toolTrail, mode: toolTrailMode })) : null, _jsx(FloatingOverlay, { columns: columns, overlay: overlay, pageSize: pagerPageSize }), _jsx(CompletionOverlay, { columns: columns, items: completions, reserved: reserveCompletionSlot, selected: selectedCompletion }), queued.length ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { dimColor: true, children: ["queued (", queued.length, ") \u00B7 \u2191\u2193 select \u00B7 Ctrl+X delete \u00B7 Esc clears"] }), queueWindow.showLead ? _jsx(Text, { dimColor: true, children: " \u2026" }) : null, queued.slice(queueWindow.start, queueWindow.end).map((q, i) => (_jsxs(Text, { color: queueWindow.start + i === activeQueueIndex ? 'yellow' : undefined, dimColor: queueWindow.start + i !== activeQueueIndex, children: [queueWindow.start + i === activeQueueIndex ? '›' : ' ', " ", queueWindow.start + i + 1, ".", ' ', compactPreview(q, Math.max(16, columns - 10))] }, `${queueWindow.start + i}-${q.slice(0, 16)}`))), queueWindow.showTail ? _jsxs(Text, { dimColor: true, children: [" \u2026and ", queued.length - queueWindow.end, " more"] }) : null] })) : null, approvalReq ? (_jsxs(Box, { marginTop: 1, borderStyle: "round", borderColor: "yellow", paddingX: 1, flexDirection: "column", children: [_jsxs(Text, { color: "yellow", children: ["\u0E2D\u0E19\u0E38\u0E21\u0E31\u0E15\u0E34\u0E23\u0E31\u0E19 ", approvalReq.tool, "?"] }), _jsx(Text, { dimColor: true, children: approvalReq.summary }), _jsx(Text, { dimColor: true, children: "y = \u0E23\u0E31\u0E19 \u00B7 n = \u0E1B\u0E0F\u0E34\u0E40\u0E2A\u0E18" })] })) : personaOpen ? (_jsx(PersonaOverlay, { onDone: (msg) => { setPersonaOpen(false); addTurn('system', msg); } })) : (_jsxs(Box, { marginTop: 1, borderStyle: "round", borderColor: busy ? 'gray' : 'blue', paddingX: 1, flexDirection: "row", flexShrink: 0, children: [_jsx(Text, { color: busy ? 'gray' : 'cyan', children: busy ? '· ' : '› ' }), _jsx(InputView, { value: editor.value, cursor: editor.cursor, busy: busy, agentStatus: agentStatus, toolTrail: toolTrail, columns: columns })] })), _jsx(Text, { dimColor: true, wrap: "truncate-end", children: footerStatus({
1230
+ branch: gitBranch,
1231
+ backgroundTaskCount: bgTaskCount,
1232
+ busy,
1233
+ columns,
1234
+ contextCompression,
1235
+ contextTokens,
1236
+ costHint,
1237
+ cwd,
1238
+ elapsedSeconds: busyElapsedSeconds,
1239
+ model,
1240
+ mode: permissionMode === 'ask' ? 'ask' : 'auto',
1241
+ queuedCount: queued.length,
1242
+ }) })] })] }));
1225
1243
  }
1226
1244
  /** input ที่มี cursor (inverse) + placeholder — minimal; รับ input ได้แม้ busy (ต่อคิว) */
1227
1245
  function InputView({ value, cursor, busy, agentStatus, toolTrail, columns = 80, }) {
@@ -1237,21 +1255,16 @@ function InputView({ value, cursor, busy, agentStatus, toolTrail, columns = 80,
1237
1255
  if (!busy && !value) {
1238
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" }));
1239
1257
  }
1240
- // multiline (กด Alt+Enter / ลงท้าย \) — สูงหลายบรรทัดตั้งใจอยู่แล้ว: render grapheme-cursor แบบ wrap ปกติ
1258
+ // multiline (กด Alt+Enter / ลงท้าย \) — สูงหลายบรรทัดตั้งใจอยู่แล้ว
1241
1259
  if (value.includes('\n')) {
1242
- const ci = cursorGraphemeIndex(value, cursor);
1243
- const graphemes = graphemesOf(value);
1244
- const before = graphemes.slice(0, ci).join('');
1245
- const at = ci < graphemes.length ? graphemes[ci] : ' ';
1246
- const after = ci < graphemes.length ? graphemes.slice(ci + 1).join('') : '';
1247
- return (_jsxs(Text, { children: [before, _jsx(Text, { inverse: true, children: at }), 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 }) }));
1248
1261
  }
1249
1262
  // บรรทัดเดียว: viewport กว้างคงที่ (เลื่อนแนวนอนแทน wrap) → กล่อง input สูง 1 บรรทัดเสมอ ไม่เด้งตอนพิมพ์ไทย
1250
1263
  // เผื่อ overhead: border(2) + paddingX(2) + prefix "› "(2) + ช่อง cursor/suffix ~2
1251
1264
  const queueHint = busy ? ' (⏎ ต่อคิว)' : '';
1252
1265
  const reserved = 8 + queueHint.length;
1253
1266
  const vp = inputViewport(value, cursor, Math.max(8, columns - reserved));
1254
- return (_jsxs(Text, { wrap: "truncate-end", children: [vp.lead ? _jsx(Text, { dimColor: true, children: SCROLL_LEAD }) : null, vp.before, _jsx(Text, { inverse: true, children: vp.at }), vp.after, vp.tail ? _jsx(Text, { dimColor: true, children: SCROLL_TAIL }) : null, queueHint ? _jsx(Text, { dimColor: true, children: queueHint }) : null] }));
1267
+ return _jsx(Text, { wrap: "truncate-end", children: formatInputLineDisplay(vp, { queueHint: queueHint || undefined }) });
1255
1268
  }
1256
1269
  function statusColor(status) {
1257
1270
  return status === 'error' ? 'red' : status === 'running' ? 'yellow' : 'green';