sanook-cli 0.5.5 → 0.5.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/ui/app.js CHANGED
@@ -6,9 +6,10 @@ import { Box, 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';
9
+ import { finalizeReplSession, formatFinalizeMessage } from '../session-brain.js';
9
10
  import { saveSession, newSessionId, listSessions, removeSession, renameSession } from '../session.js';
10
11
  import { TOOL_CATALOG } from '../tool-catalog.js';
11
- import { getBrainPath, appendBrainWorklog } from '../memory.js';
12
+ import { getBrainPath, appendBrainWorklog, appendBrainTranscript } from '../memory.js';
12
13
  import { autoCompact, estimateTokens, summarizeCompact } from '../compaction.js';
13
14
  import { makeSummarizer } from '../summarize.js';
14
15
  import { agentTuning, patchGlobalConfig } from '../config.js';
@@ -18,7 +19,9 @@ import { loadMcpHubEntries } from '../mcp-hub.js';
18
19
  import { probeMcpServer } from '../mcp.js';
19
20
  import { filterModelPickerOptions, initialModelPickerIndex, modelPickerOptions, modelProviderEntries, } from '../model-picker.js';
20
21
  import { clampCompletionIndex, completionForInput, completionReplaceValue } from '../slash-completion.js';
21
- import { loadSkills } from '../skills.js';
22
+ import { loadSkills, saveSkill } from '../skills.js';
23
+ import { maybeAutoSkill } from '../self-improve.js';
24
+ import { defaultSkillSynthesizer } from '../self-improve-synth.js';
22
25
  import { copyTextToClipboard } from '../clipboard.js';
23
26
  import { useEditor } from './useEditor.js';
24
27
  import { useBusyElapsedSeconds } from './useBusyElapsed.js';
@@ -34,8 +37,10 @@ import { MarkdownText, StreamingMarkdownText } from './markdown.js';
34
37
  import { SessionPanel } from './session-panel.js';
35
38
  import { getTranscriptWindow, transcriptScrollStep, transcriptWindowSize } from './transcript.js';
36
39
  import { footerStatus } from './status.js';
40
+ import { inputViewport, graphemesOf, cursorGraphemeIndex, SCROLL_LEAD, SCROLL_TAIL } from './input-view.js';
41
+ import { PersonaOverlay } from './persona-wizard.js';
37
42
  import { thinkingPanelLines, snapshotThinking } from './thinking-panel.js';
38
- import { toolTrailLines, updateToolTrailOnEvent } from './tool-trail.js';
43
+ import { toolTrailLines, toolTrailHeader, toolTrailWidth, updateToolTrailOnEvent } from './tool-trail.js';
39
44
  const execFileP = promisify(execFile);
40
45
  const PRE_TURN_COMPACT_TOKENS = 100_000; // session ยาวมากเท่านั้นถึง summarize ก่อน turn (mode summarize)
41
46
  const startupCount = (value) => value === 'checking' ? 'checking' : value.count ? `${value.count}` : 'none';
@@ -53,6 +58,7 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
53
58
  });
54
59
  const [streaming, setStreaming] = useState('');
55
60
  const [thinking, setThinking] = useState('');
61
+ const [agentStatus, setAgentStatus] = useState('');
56
62
  const [toolTrail, setToolTrail] = useState([]);
57
63
  const [busy, setBusy] = useState(false);
58
64
  const [model, setModel] = useState(initialModel);
@@ -65,6 +71,7 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
65
71
  const [thinkingMode, setThinkingMode] = useState('collapsed');
66
72
  const [contextCompression, setContextCompression] = useState();
67
73
  const [transcriptScroll, setTranscriptScroll] = useState(0);
74
+ const [personaOpen, setPersonaOpen] = useState(false);
68
75
  const idRef = useRef(0);
69
76
  const lastCost = useRef('');
70
77
  const nextToolTrailId = useRef(0);
@@ -74,6 +81,7 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
74
81
  const msgsRef = useRef(initialHistory ?? []); // conversation จริงสำหรับ LLM (สะสมข้ามรอบ)
75
82
  const sessionId = useRef(newSessionId());
76
83
  const sessionCreated = useRef(new Date().toISOString());
84
+ const exitingRef = useRef(false);
77
85
  const approvalResolve = useRef(null);
78
86
  const replHistory = useRef(loadHistory()); // prompt เก่า (persist) สำหรับ ↑/↓
79
87
  const checkpoints = useRef([]);
@@ -259,6 +267,26 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
259
267
  setCompletionIndex(0);
260
268
  return true;
261
269
  };
270
+ const requestExit = () => {
271
+ if (exitingRef.current)
272
+ return;
273
+ exitingRef.current = true;
274
+ void finalizeReplSession({
275
+ sessionId: sessionId.current,
276
+ sessionCreated: sessionCreated.current,
277
+ model,
278
+ cwd,
279
+ messages: msgsRef.current,
280
+ history: history.map((turn) => ({ role: turn.role, text: turn.text })),
281
+ })
282
+ .then((result) => {
283
+ const note = formatFinalizeMessage(result);
284
+ if (note)
285
+ process.stderr.write(`\n${note}\n`);
286
+ exit();
287
+ })
288
+ .catch(() => exit());
289
+ };
262
290
  // /diff /undo — git-backed (execFile ไม่ผ่าน shell)
263
291
  async function runGit(args, label) {
264
292
  try {
@@ -817,7 +845,7 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
817
845
  if (editor.value)
818
846
  editor.reset(); // Ctrl+C ครั้งแรก = ล้างบรรทัด, ว่างแล้ว = ออก
819
847
  else
820
- exit();
848
+ requestExit();
821
849
  }
822
850
  });
823
851
  /** ย้อน 1 turn — คืนไฟล์ (git, recoverable) + ตัดบทสนทนากลับ */
@@ -910,7 +938,7 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
910
938
  if (cmd.handled) {
911
939
  addTurn('user', displayText);
912
940
  if (cmd.action === 'quit')
913
- return exit();
941
+ return requestExit();
914
942
  if (cmd.action === 'clear') {
915
943
  msgsRef.current = [];
916
944
  checkpoints.current = [];
@@ -938,6 +966,10 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
938
966
  .catch((e) => addTurn('system', `personality: ${e.message}`));
939
967
  return;
940
968
  }
969
+ if (cmd.action === 'personaSetup') {
970
+ setPersonaOpen(true);
971
+ return;
972
+ }
941
973
  if (cmd.action === 'insights') {
942
974
  void renderInsights({ days: cmd.insightsDays, cwd: cmd.insightsAll ? null : undefined })
943
975
  .then((msg) => addTurn('system', msg))
@@ -1023,11 +1055,13 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
1023
1055
  resetLiveToolTrail();
1024
1056
  resetLiveThinking();
1025
1057
  setStreaming('');
1058
+ setAgentStatus('Starting…');
1026
1059
  setBusy(true);
1027
1060
  let buf = '';
1028
1061
  let reasoningBuf = '';
1029
1062
  let lastFlush = 0;
1030
1063
  let lastThinkingFlush = 0;
1064
+ const rememberedFacts = []; // 🧠 ที่ user สั่งจำใน turn นี้ → โชว์ indicator ใน terminal
1031
1065
  try {
1032
1066
  const { cost, messages, text } = await runAgent({
1033
1067
  model,
@@ -1039,8 +1073,13 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
1039
1073
  permissionMode,
1040
1074
  approve: requestApproval,
1041
1075
  signal: ac.signal,
1076
+ usageMeta: { sessionId: sessionId.current, source: 'repl' },
1042
1077
  onEvent: (e) => {
1043
- if (e.type === 'text') {
1078
+ if (e.type === 'status' && typeof e.detail === 'string') {
1079
+ setAgentStatus(e.detail);
1080
+ }
1081
+ else if (e.type === 'text') {
1082
+ setAgentStatus((prev) => (prev.startsWith('Codex') || prev.startsWith('Agent') ? 'Writing…' : prev));
1044
1083
  buf += e.text ?? '';
1045
1084
  const now = Date.now();
1046
1085
  if (now - lastFlush > 80) {
@@ -1049,6 +1088,11 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
1049
1088
  }
1050
1089
  }
1051
1090
  else if (e.type === 'tool-call') {
1091
+ if (e.tool === 'remember') {
1092
+ const fact = e.detail?.fact;
1093
+ if (typeof fact === 'string' && fact.trim())
1094
+ rememberedFacts.push(fact.trim());
1095
+ }
1052
1096
  recordToolTrailEvent(e);
1053
1097
  }
1054
1098
  else if (e.type === 'tool-result' || e.type === 'error') {
@@ -1067,7 +1111,11 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
1067
1111
  });
1068
1112
  msgsRef.current = messages;
1069
1113
  lastCost.current = cost.summary();
1070
- addTurn('assistant', buf.trim() || text.trim(), { thinking: snapshotThinking(reasoningBuf), toolTrail: snapshotToolTrail() });
1114
+ const answerText = buf.trim() || text.trim();
1115
+ addTurn('assistant', answerText, { thinking: snapshotThinking(reasoningBuf), toolTrail: snapshotToolTrail() });
1116
+ // 🧠 indicator: ผู้ใช้สั่งให้จำ → บันทึกถาวรแล้ว (memory + second-brain ถ้าตั้งไว้)
1117
+ for (const fact of rememberedFacts)
1118
+ addTurn('system', `🧠 จำไว้แล้ว: ${fact}`);
1071
1119
  // เซฟ session ทุกรอบ → resume ได้ด้วย sanook -c
1072
1120
  void saveSession({
1073
1121
  id: sessionId.current,
@@ -1077,7 +1125,7 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
1077
1125
  cwd: process.cwd(),
1078
1126
  messages,
1079
1127
  });
1080
- // worklog เข้า second-brain vault จำว่าทำอะไรใน session นี้
1128
+ // worklog (ย่อ) + บทสนทนาเต็ม (ถ้าเปิด brainTranscript) เข้า second-brain
1081
1129
  void (async () => {
1082
1130
  const brain = await getBrainPath();
1083
1131
  if (brain) {
@@ -1087,6 +1135,31 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
1087
1135
  model,
1088
1136
  today: new Date().toISOString().slice(0, 10),
1089
1137
  }).catch(() => { });
1138
+ await appendBrainTranscript(brain, {
1139
+ sessionId: sessionId.current,
1140
+ prompt: promptText,
1141
+ answer: answerText,
1142
+ model,
1143
+ // per-turn timestamp → correct HH:MM heading + correct day file (matches the worklog
1144
+ // above); sessionCreated is frozen at session start so it stamped every turn the same.
1145
+ createdIso: new Date().toISOString(),
1146
+ }).catch(() => { });
1147
+ }
1148
+ })();
1149
+ // self-improvement: งานเดิมที่สั่งซ้ำถึง threshold → สร้าง skill อัตโนมัติ + แจ้งใน terminal
1150
+ void (async () => {
1151
+ try {
1152
+ const existing = new Set((await loadSkills()).map((s) => s.name));
1153
+ const result = await maybeAutoSkill(promptText, {
1154
+ synthesize: defaultSkillSynthesizer(model),
1155
+ saveSkill,
1156
+ existingSkillNames: existing,
1157
+ });
1158
+ if (result.created && result.announcement)
1159
+ addTurn('system', result.announcement);
1160
+ }
1161
+ catch {
1162
+ /* self-improvement เป็น best-effort — ไม่ให้ล้ม turn */
1090
1163
  }
1091
1164
  })();
1092
1165
  }
@@ -1103,6 +1176,7 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
1103
1176
  }
1104
1177
  finally {
1105
1178
  setStreaming('');
1179
+ setAgentStatus('');
1106
1180
  resetLiveThinking();
1107
1181
  resetLiveToolTrail();
1108
1182
  setBusy(false);
@@ -1117,12 +1191,24 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
1117
1191
  const contextTokens = estimateTokens(msgsRef.current);
1118
1192
  const activeQueueIndex = clampQueueActiveIndex(queueActiveIndex, queued.length);
1119
1193
  const queueWindow = getQueueWindow(queued.length, activeQueueIndex);
1120
- const toolTrailView = toolTrailLines(toolTrail, columns, toolTrailMode);
1194
+ // gate only the expanded view renders via ToolTrailView/ActivityRow and the compact view
1195
+ // recomputes its own strings; equivalent to toolTrailLines(...).length without building the array each render.
1196
+ const showToolTrail = toolTrailMode !== 'hidden' && toolTrail.length > 0;
1197
+ // id of the most recent turn that has a tool trail — only it keeps full expanded diffs in
1198
+ // scrollback (older tool turns downgrade to compact). Tracks the trail, not just the last turn,
1199
+ // so trailing text/command turns don't strip detail off the latest tool work.
1200
+ let latestTrailTurnId;
1201
+ for (let i = history.length - 1; i >= 0; i -= 1) {
1202
+ if (history[i].toolTrail) {
1203
+ latestTrailTurnId = history[i].id;
1204
+ break;
1205
+ }
1206
+ }
1121
1207
  const thinkingView = thinkingPanelLines(thinking, columns, thinkingMode);
1122
1208
  const transcriptLimit = transcriptWindowSize(stdout?.rows);
1123
1209
  const transcriptView = getTranscriptWindow(history.length, transcriptLimit, transcriptScroll);
1124
1210
  const visibleHistory = history.slice(transcriptView.start, transcriptView.end);
1125
- 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, 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, toolTrailView.length ? (_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" })] })) : (_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 })] })), _jsx(Text, { dimColor: true, children: footerStatus({
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({
1126
1212
  branch: gitBranch,
1127
1213
  backgroundTaskCount: bgTaskCount,
1128
1214
  busy,
@@ -1138,27 +1224,65 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
1138
1224
  }) })] }));
1139
1225
  }
1140
1226
  /** input ที่มี cursor (inverse) + placeholder — minimal; รับ input ได้แม้ busy (ต่อคิว) */
1141
- function InputView({ value, cursor, busy }) {
1142
- if (busy && !value)
1143
- return _jsx(Text, { dimColor: true, children: "\u0E01\u0E33\u0E25\u0E31\u0E07\u0E17\u0E33\u0E07\u0E32\u0E19\u2026 Esc/Ctrl+C \u0E2B\u0E22\u0E38\u0E14 \u00B7 \u0E1E\u0E34\u0E21\u0E1E\u0E4C\u0E40\u0E1E\u0E37\u0E48\u0E2D\u0E15\u0E48\u0E2D\u0E04\u0E34\u0E27 (\u23CE)" });
1144
- if (!busy && !value)
1145
- return _jsx(Text, { dimColor: true, 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" });
1146
- const before = value.slice(0, cursor);
1147
- const at = value.slice(cursor, cursor + 1) || ' ';
1148
- const after = value.slice(cursor + 1);
1149
- 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] }));
1227
+ function InputView({ value, cursor, busy, agentStatus, toolTrail, columns = 80, }) {
1228
+ if (busy && !value) {
1229
+ const runningTool = toolTrail?.find((item) => item.status === 'running');
1230
+ // prefer the running tool's friendly activity title (e.g. "📖 อ่านไฟล์ src/x.ts", "$ npm test") over
1231
+ // the raw "Tool · read_file" status same detail the tool trail shows, so the one-line status during a
1232
+ // turn says specifically what's happening; falls back to agentStatus (Thinking…/Writing…/Agent · model).
1233
+ const detail = runningTool?.activity?.title || agentStatus || (runningTool ? `Tool · ${runningTool.name}` : 'Working…');
1234
+ // wrap="truncate-end": status ต้องอยู่ 1 บรรทัดเสมอ — กัน timer/elapsed ทำบรรทัดเด้งหลังส่ง prompt
1235
+ return (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [detail, " \u00B7 Esc/Ctrl+C \u0E2B\u0E22\u0E38\u0E14 \u00B7 \u0E1E\u0E34\u0E21\u0E1E\u0E4C\u0E40\u0E1E\u0E37\u0E48\u0E2D\u0E15\u0E48\u0E2D\u0E04\u0E34\u0E27 (\u23CE)"] }));
1236
+ }
1237
+ if (!busy && !value) {
1238
+ 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
+ }
1240
+ // multiline (กด Alt+Enter / ลงท้าย \) — สูงหลายบรรทัดตั้งใจอยู่แล้ว: render grapheme-cursor แบบ wrap ปกติ
1241
+ 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] }));
1248
+ }
1249
+ // บรรทัดเดียว: viewport กว้างคงที่ (เลื่อนแนวนอนแทน wrap) → กล่อง input สูง 1 บรรทัดเสมอ ไม่เด้งตอนพิมพ์ไทย
1250
+ // เผื่อ overhead: border(2) + paddingX(2) + prefix "› "(2) + ช่อง cursor/suffix ~2
1251
+ const queueHint = busy ? ' (⏎ ต่อคิว)' : '';
1252
+ const reserved = 8 + queueHint.length;
1253
+ 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] }));
1255
+ }
1256
+ function statusColor(status) {
1257
+ return status === 'error' ? 'red' : status === 'running' ? 'yellow' : 'green';
1258
+ }
1259
+ function statusMarker(status) {
1260
+ return status === 'error' ? '✗' : status === 'running' ? '›' : '✓';
1261
+ }
1262
+ // total diff rows rendered per tool row — a hard per-item height bound. diffLines caps each SIDE at
1263
+ // MAX_DIFF_LINES, so a two-sided edit can exceed this; we cap the combined rows here and show a plain
1264
+ // "…" (no count — the inner per-side "…(+N)" sentinels already carry the numbers, so we don't double-count).
1265
+ const MAX_ROW_DIFF_LINES = 14;
1266
+ /** one tool's activity: a friendly title line + colored diff (green +, red -), height-bounded. */
1267
+ function ActivityRow({ item, width }) {
1268
+ const title = item.activity?.title ?? item.name;
1269
+ const fullDiff = item.activity?.diff;
1270
+ const diff = fullDiff?.slice(0, MAX_ROW_DIFF_LINES);
1271
+ const diffClipped = (fullDiff?.length ?? 0) > MAX_ROW_DIFF_LINES;
1272
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: statusColor(item.status), wrap: "truncate-end", children: [statusMarker(item.status), " ", title] }), diff?.map((line, idx) => (_jsxs(Text, { color: line.sign === '+' ? 'green' : line.sign === '-' ? 'red' : undefined, dimColor: line.sign === ' ', wrap: "truncate-end", children: [' ', line.sign === ' ' ? ' ' : line.sign, " ", line.text.slice(0, Math.max(0, width - 4))] }, `d-${item.id}-${idx}`))), diffClipped ? _jsx(Text, { dimColor: true, children: ' …' }) : null, item.status !== 'running' && item.detail ? (_jsxs(Text, { color: item.status === 'error' ? 'red' : undefined, dimColor: true, wrap: "truncate-end", children: [' ↳ ', item.detail.slice(0, Math.max(0, width - 6))] })) : null] }));
1150
1273
  }
1151
1274
  function ToolTrailView({ columns, items, mode }) {
1152
- const lines = toolTrailLines(items, columns, mode);
1153
- if (!lines.length)
1275
+ if (mode === 'hidden' || !items.length)
1154
1276
  return null;
1155
- return (_jsx(Box, { flexDirection: "column", marginTop: 1, children: lines.map((line, index) => {
1156
- const isRunning = line.startsWith('>');
1157
- const isError = line.startsWith('!');
1158
- const isDone = line.startsWith('+');
1159
- const isMeta = line.startsWith('view:') || line.startsWith('tools:');
1160
- return (_jsx(Text, { color: index === 0 ? 'cyan' : isError ? 'red' : isRunning ? 'yellow' : undefined, dimColor: isDone || isMeta, wrap: "truncate-end", children: line }, `${index}-${line}`));
1161
- }) }));
1277
+ const header = toolTrailHeader(items, mode);
1278
+ // compact mode keeps the terse one-line string rendering
1279
+ if (mode === 'compact') {
1280
+ const lines = toolTrailLines(items, columns, mode);
1281
+ return (_jsx(Box, { flexDirection: "column", marginTop: 1, children: lines.map((line, index) => (_jsx(Text, { color: index === 0 ? 'cyan' : undefined, dimColor: index > 0, wrap: "truncate-end", children: line }, `${index}-${line}`))) }));
1282
+ }
1283
+ // expanded: rich, colored per-tool activity with diffs
1284
+ const width = toolTrailWidth(columns);
1285
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "cyan", wrap: "truncate-end", children: header[0] }), _jsx(Text, { dimColor: true, wrap: "truncate-end", children: header[1] }), items.map((item) => (_jsx(ActivityRow, { item: item, width: width }, item.id)))] }));
1162
1286
  }
1163
1287
  function ThinkingView({ columns, mode, text }) {
1164
1288
  const lines = thinkingPanelLines(text, columns, mode);
@@ -1166,10 +1290,10 @@ function ThinkingView({ columns, mode, text }) {
1166
1290
  return null;
1167
1291
  return (_jsx(Box, { flexDirection: "column", marginTop: 1, children: lines.map((line, index) => (_jsx(Text, { color: index === 0 ? 'cyan' : undefined, dimColor: index > 0, wrap: "truncate-end", children: line }, `${index}-${line}`))) }));
1168
1292
  }
1169
- function TurnView({ columns, thinkingMode, toolTrailMode, turn, }) {
1293
+ function TurnView({ columns, isLatest, thinkingMode, toolTrailMode, turn, }) {
1170
1294
  if (turn.role === 'system')
1171
1295
  return _jsx(Text, { dimColor: true, children: turn.text });
1172
1296
  if (turn.role === 'user')
1173
1297
  return (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "cyan", children: "\u203A " }), _jsx(Text, { color: "cyan", children: turn.text })] }));
1174
- return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [turn.thinking ? _jsx(ThinkingView, { columns: columns, mode: thinkingMode, text: turn.thinking }) : null, _jsx(MarkdownText, { columns: columns, text: turn.text }), turn.toolTrail ? _jsx(ToolTrailView, { columns: columns, items: turn.toolTrail, mode: toolTrailMode }) : null] }));
1298
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [turn.thinking ? _jsx(ThinkingView, { columns: columns, mode: thinkingMode, text: turn.thinking }) : null, _jsx(MarkdownText, { columns: columns, text: turn.text }), turn.toolTrail ? (_jsx(ToolTrailView, { columns: columns, items: turn.toolTrail, mode: isLatest || toolTrailMode !== 'expanded' ? toolTrailMode : 'compact' })) : null] }));
1175
1299
  }
@@ -0,0 +1,104 @@
1
+ import stringWidth from 'string-width';
2
+ import { graphemeBoundaries } from './useEditor.js';
3
+ // ────────────────────────────────────────────────────────────────────────────
4
+ // Stable, Thai-safe rendering of the REPL input line.
5
+ //
6
+ // Two bugs this fixes (เทียบกับ CLI เจ้าอื่นที่ "นิ่ง"):
7
+ // 1) cursor split a grapheme cluster — the old code did value.slice(cursor, cursor+1),
8
+ // which on Thai cuts a base char away from its combining vowel/tone mark (สระ/วรรณยุกต์
9
+ // เป็น zero-width). The orphaned mark then renders on its own cell → "อักษรห่างเกินไป".
10
+ // Fix: the cursor highlights a WHOLE grapheme cluster (base + all its marks).
11
+ // 2) the line bounced between 1 and 2 rows while typing — a wrapping <Text> grows the box
12
+ // vertically the moment content crosses the right edge, shoving the footer down on every
13
+ // keystroke. Fix: a fixed-width horizontal viewport (readline-style) so the input box is
14
+ // always exactly one row; long lines scroll left with ‹ / › markers instead of wrapping.
15
+ //
16
+ // Display width is measured with string-width (the same lib Ink wraps with), so Thai combining
17
+ // marks count as 0 and the window math matches what the terminal actually paints.
18
+ // ────────────────────────────────────────────────────────────────────────────
19
+ export const SCROLL_LEAD = '‹';
20
+ export const SCROLL_TAIL = '›';
21
+ /** split a string into grapheme clusters (base char + its combining marks stay together) */
22
+ export function graphemesOf(value) {
23
+ const bounds = graphemeBoundaries(value);
24
+ const out = [];
25
+ for (let i = 0; i < bounds.length - 1; i += 1)
26
+ out.push(value.slice(bounds[i], bounds[i + 1]));
27
+ return out;
28
+ }
29
+ /** grapheme-cluster index that a code-unit cursor sits at (0..graphemeCount) */
30
+ export function cursorGraphemeIndex(value, cursor) {
31
+ const bounds = graphemeBoundaries(value);
32
+ let index = 0;
33
+ for (let i = 0; i < bounds.length; i += 1) {
34
+ if (bounds[i] <= cursor)
35
+ index = i;
36
+ else
37
+ break;
38
+ }
39
+ return index;
40
+ }
41
+ /** display width of one grapheme, never less than 1 cell (so the cursor always has a cell) */
42
+ function cellWidth(grapheme) {
43
+ return Math.max(1, stringWidth(grapheme));
44
+ }
45
+ /**
46
+ * Compute the visible window for a single physical line.
47
+ * `width` = number of terminal columns available to the value (caller subtracts prefix/border/padding).
48
+ * Returns the slice around the cursor that fits, plus whether truncation markers are needed.
49
+ */
50
+ export function inputViewport(value, cursor, width) {
51
+ const w = Math.max(4, Math.floor(width));
52
+ const graphemes = graphemesOf(value);
53
+ const ci = cursorGraphemeIndex(value, cursor);
54
+ // a trailing sentinel cell so a cursor parked at end-of-line still has somewhere to sit
55
+ const units = [...graphemes.map((g) => ({ text: g, width: cellWidth(g) })), { text: ' ', width: 1 }];
56
+ const cursorUnit = Math.min(ci, units.length - 1);
57
+ const totalWidth = units.reduce((sum, u) => sum + u.width, 0);
58
+ if (totalWidth <= w) {
59
+ return {
60
+ lead: false,
61
+ before: graphemes.slice(0, cursorUnit).join(''),
62
+ at: cursorUnit < graphemes.length ? graphemes[cursorUnit] : ' ',
63
+ after: cursorUnit < graphemes.length ? graphemes.slice(cursorUnit + 1).join('') : '',
64
+ tail: false,
65
+ };
66
+ }
67
+ // overflow → slide a window that always contains the cursor unit; reserve 1 cell for each
68
+ // truncation marker that will actually be shown.
69
+ let start = cursorUnit;
70
+ let end = cursorUnit + 1;
71
+ let used = units[cursorUnit].width;
72
+ // extend right first (so typing at end keeps the tail in view), then backfill left context.
73
+ // the marker reservations (start>0 ⇒ ‹, end<len ⇒ ›) are folded into each fit check.
74
+ while (end < units.length) {
75
+ const next = units[end].width;
76
+ if (used + next + (start > 0 ? 1 : 0) + (end + 1 < units.length ? 1 : 0) <= w) {
77
+ used += next;
78
+ end += 1;
79
+ }
80
+ else
81
+ break;
82
+ }
83
+ while (start > 0) {
84
+ const prev = units[start - 1].width;
85
+ if (used + prev + (start - 1 > 0 ? 1 : 0) + (end < units.length ? 1 : 0) <= w) {
86
+ used += prev;
87
+ start -= 1;
88
+ }
89
+ else
90
+ break;
91
+ }
92
+ const slice = (from, to) => units
93
+ .slice(from, to)
94
+ .map((u) => u.text)
95
+ .join('');
96
+ const atUnit = units[cursorUnit];
97
+ return {
98
+ lead: start > 0,
99
+ before: slice(start, cursorUnit),
100
+ at: cursorUnit === units.length - 1 ? ' ' : atUnit.text,
101
+ after: slice(cursorUnit + 1, end),
102
+ tail: end < units.length,
103
+ };
104
+ }
@@ -0,0 +1,89 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState, useEffect } from 'react';
3
+ import { Box, Text, useInput } from 'ink';
4
+ import { TextInput, Select } from '@inkjs/ui';
5
+ import { PERSONA_QUESTIONS, PERSONA_OTHER } from '../persona.js';
6
+ /**
7
+ * Interactive persona questionnaire (`sanook persona`). Walks PERSONA_QUESTIONS one at a
8
+ * time — A/B/C/D Selects and free-text inputs. A select "อื่นๆ (พิมพ์เอง)" drops into a
9
+ * free-text follow-up for that same question. Esc goes back one step. Calls onComplete with
10
+ * the full answers map when finished.
11
+ */
12
+ export function PersonaWizard({ onComplete, initialAnswers = {}, }) {
13
+ const total = PERSONA_QUESTIONS.length;
14
+ const [index, setIndex] = useState(0);
15
+ const [answers, setAnswers] = useState(initialAnswers);
16
+ const [otherMode, setOtherMode] = useState(false);
17
+ const prefilled = Object.keys(initialAnswers).filter((k) => initialAnswers[k]?.trim()).length;
18
+ const q = PERSONA_QUESTIONS[index];
19
+ const goBack = () => {
20
+ if (otherMode) {
21
+ setOtherMode(false);
22
+ return;
23
+ }
24
+ if (index > 0)
25
+ setIndex(index - 1);
26
+ };
27
+ useInput((_input, key) => {
28
+ if (key.escape)
29
+ goBack();
30
+ });
31
+ const advance = (value) => {
32
+ const next = { ...answers, [q.id]: value };
33
+ setAnswers(next);
34
+ setOtherMode(false);
35
+ if (index + 1 >= total) {
36
+ onComplete(next);
37
+ }
38
+ else {
39
+ setIndex(index + 1);
40
+ }
41
+ };
42
+ const showTextInput = q.type === 'text' || otherMode;
43
+ // On Esc-back, surface the previously-chosen option FIRST so the highlight lands on it. @inkjs/ui
44
+ // Select always focuses the first option on (re)mount and ignores defaultValue for the highlight, so
45
+ // with no defaultValue pressing Enter selects the focused (first) option — making that the prior
46
+ // answer keeps "Esc-back then Enter to re-confirm" from silently overwriting it with the first option.
47
+ // If the prior answer was a custom "อื่นๆ" value (not a literal option), highlight the PERSONA_OTHER
48
+ // option so Enter re-enters other-mode (where the TextInput restores the typed text via defaultValue).
49
+ const selectOptions = (() => {
50
+ const opts = (q.options ?? []).map((o) => ({ label: o.label, value: o.value }));
51
+ const prior = answers[q.id];
52
+ if (!prior)
53
+ return opts;
54
+ let i = opts.findIndex((o) => o.value === prior);
55
+ if (i < 0)
56
+ i = opts.findIndex((o) => o.value === PERSONA_OTHER); // custom typed value → highlight "อื่นๆ"
57
+ return i > 0 ? [opts[i], ...opts.slice(0, i), ...opts.slice(i + 1)] : opts;
58
+ })();
59
+ return (_jsxs(Box, { flexDirection: "column", gap: 1, marginY: 1, children: [_jsxs(Text, { bold: true, color: "cyan", children: ["\uD83E\uDEAA \u0E15\u0E31\u0E49\u0E07\u0E04\u0E48\u0E32 Persona ", _jsx(Text, { color: "gray", children: "\u2014 \u0E1A\u0E2D\u0E01 AI \u0E27\u0E48\u0E32\u0E04\u0E38\u0E13\u0E40\u0E1B\u0E47\u0E19\u0E43\u0E04\u0E23 + \u0E2D\u0E22\u0E32\u0E01\u0E43\u0E2B\u0E49\u0E17\u0E33\u0E07\u0E32\u0E19\u0E22\u0E31\u0E07\u0E44\u0E07" })] }), _jsxs(Text, { color: "gray", children: ["\u0E02\u0E49\u0E2D ", index + 1, "/", total, " \u00B7 Esc = \u0E22\u0E49\u0E2D\u0E19\u0E01\u0E25\u0E31\u0E1A \u00B7 Ctrl+C = \u0E2D\u0E2D\u0E01", prefilled ? ` · โหลดค่าเดิม ${prefilled} ข้อ` : ''] }), _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: q.prompt }), showTextInput ? (_jsx(TextInput, { defaultValue: answers[q.id] ?? '', placeholder: otherMode ? 'พิมพ์คำตอบของคุณ…' : (q.placeholder ?? ''), onSubmit: (v) => advance(v.trim()) }, `text-${index}-${otherMode ? 'other' : 'main'}`)) : (_jsx(Select, { options: selectOptions, onChange: (v) => {
60
+ if (v === PERSONA_OTHER)
61
+ setOtherMode(true);
62
+ else
63
+ advance(v);
64
+ } }, `select-${index}`))] })] }));
65
+ }
66
+ /** REPL overlay — `/persona` loads existing answers then runs the same wizard inline */
67
+ export function PersonaOverlay({ onDone }) {
68
+ const [initialAnswers, setInitialAnswers] = useState(null);
69
+ useEffect(() => {
70
+ void import('../memory.js')
71
+ .then((m) => m.loadPersonaAnswers())
72
+ .then(setInitialAnswers)
73
+ .catch(() => setInitialAnswers({}));
74
+ }, []);
75
+ if (!initialAnswers) {
76
+ return (_jsx(Box, { marginY: 1, children: _jsx(Text, { color: "gray", children: "\u0E01\u0E33\u0E25\u0E31\u0E07\u0E42\u0E2B\u0E25\u0E14 persona\u2026" }) }));
77
+ }
78
+ return (_jsx(PersonaWizard, { initialAnswers: initialAnswers, onComplete: (answers) => {
79
+ void (async () => {
80
+ const { persistPersonaAnswers } = await import('../memory.js');
81
+ const { memoryWritten, vaultWritten, brainPath } = await persistPersonaAnswers(answers);
82
+ const memLine = memoryWritten > 0
83
+ ? `จำเข้า memory ${memoryWritten} ข้อ`
84
+ : 'ไม่มีข้อมูลใหม่ (ตรงกับของเดิม)';
85
+ const vaultLine = vaultWritten ? ` · vault: ${brainPath}/Shared/User-Persona/persona.md` : '';
86
+ onDone(`✅ บันทึก Persona — ${memLine}${vaultLine}`);
87
+ })();
88
+ } }));
89
+ }