sanook-cli 0.5.10 → 0.5.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -0
- package/dist/commands.js +14 -0
- package/dist/i18n/en.js +5 -1
- package/dist/i18n/th.js +5 -1
- package/dist/loop.js +48 -19
- package/dist/memory-store.js +20 -7
- package/dist/memory.js +57 -6
- package/dist/persona.js +10 -0
- package/dist/prompt-safety.js +13 -0
- package/dist/prompt-size.js +5 -3
- package/dist/ui/app.js +38 -25
- package/dist/ui/banner.js +4 -6
- package/dist/ui/brain-wizard.js +6 -4
- package/dist/ui/input-view.js +55 -26
- package/dist/ui/markdown.js +3 -3
- package/dist/ui/overlay.js +36 -18
- package/dist/ui/queue.js +3 -2
- package/dist/ui/render.js +27 -8
- package/dist/ui/session-panel.js +3 -5
- package/dist/ui/setup.js +21 -19
- package/dist/ui/status.js +8 -9
- package/dist/ui/text-width.js +52 -0
- package/dist/ui/thinking-panel.js +4 -3
- package/dist/ui/tool-trail.js +4 -4
- package/package.json +1 -1
package/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: '
|
|
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
|
@@ -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,
|
|
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
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
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 / ลงท้าย \) —
|
|
1258
|
+
// multiline (กด Alt+Enter / ลงท้าย \) — สูงหลายบรรทัดตั้งใจอยู่แล้ว
|
|
1241
1259
|
if (value.includes('\n')) {
|
|
1242
|
-
|
|
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 (
|
|
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';
|