sanook-cli 0.5.2 → 0.5.7

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.
Files changed (127) hide show
  1. package/CHANGELOG.md +112 -2
  2. package/README.md +15 -3
  3. package/README.th.md +8 -1
  4. package/dist/approval.js +7 -0
  5. package/dist/bin.js +637 -56
  6. package/dist/brain-consolidate.js +335 -0
  7. package/dist/brain-context.js +42 -3
  8. package/dist/brain-final.js +15 -9
  9. package/dist/brain-link.js +73 -0
  10. package/dist/brain-metrics.js +277 -0
  11. package/dist/brain-new.js +402 -0
  12. package/dist/brain-pack.js +210 -0
  13. package/dist/brain-repair.js +280 -0
  14. package/dist/brain.js +3 -0
  15. package/dist/brand.js +4 -0
  16. package/dist/cli-args.js +47 -9
  17. package/dist/cli-option-values.js +1 -1
  18. package/dist/clipboard.js +65 -0
  19. package/dist/commands.js +98 -15
  20. package/dist/config.js +66 -34
  21. package/dist/context-pack.js +145 -0
  22. package/dist/cost.js +20 -0
  23. package/dist/dashboard/api-helpers.js +87 -0
  24. package/dist/dashboard/server.js +179 -0
  25. package/dist/dashboard/static/app.js +277 -0
  26. package/dist/dashboard/static/index.html +39 -0
  27. package/dist/dashboard/static/styles.css +85 -0
  28. package/dist/diff.js +10 -2
  29. package/dist/gateway/auth.js +14 -3
  30. package/dist/gateway/deliver.js +45 -3
  31. package/dist/gateway/doctor.js +456 -0
  32. package/dist/gateway/email.js +30 -1
  33. package/dist/gateway/ledger.js +20 -1
  34. package/dist/gateway/session.js +34 -11
  35. package/dist/hotkeys.js +21 -0
  36. package/dist/i18n/en.js +98 -0
  37. package/dist/i18n/index.js +19 -0
  38. package/dist/i18n/th.js +98 -0
  39. package/dist/i18n/types.js +1 -0
  40. package/dist/insights-args.js +24 -4
  41. package/dist/knowledge.js +55 -29
  42. package/dist/loop.js +65 -9
  43. package/dist/mcp-hub.js +33 -0
  44. package/dist/mcp-registry.js +153 -9
  45. package/dist/mcp-risk.js +71 -0
  46. package/dist/mcp.js +77 -5
  47. package/dist/memory-log.js +90 -0
  48. package/dist/memory-store.js +37 -1
  49. package/dist/memory.js +51 -7
  50. package/dist/model-picker.js +58 -0
  51. package/dist/orchestrate.js +7 -5
  52. package/dist/plan-handoff.js +17 -0
  53. package/dist/polyglot.js +162 -0
  54. package/dist/process-runner.js +96 -0
  55. package/dist/project-init.js +91 -0
  56. package/dist/project-registry.js +143 -0
  57. package/dist/project-scaffold.js +124 -0
  58. package/dist/prompt-size.js +155 -0
  59. package/dist/providers/codex-login.js +138 -0
  60. package/dist/providers/codex.js +20 -8
  61. package/dist/providers/keys.js +21 -0
  62. package/dist/providers/models.js +1 -1
  63. package/dist/providers/registry.js +11 -1
  64. package/dist/search/cli.js +9 -1
  65. package/dist/search/embedding-config.js +22 -0
  66. package/dist/search/engine.js +2 -13
  67. package/dist/search/indexer.js +10 -10
  68. package/dist/session-brain.js +103 -0
  69. package/dist/session-distill.js +84 -0
  70. package/dist/session.js +1 -11
  71. package/dist/skill-install.js +24 -1
  72. package/dist/skills.js +33 -0
  73. package/dist/slash-completion.js +155 -0
  74. package/dist/support-dump.js +31 -0
  75. package/dist/tool-catalog.js +59 -0
  76. package/dist/tools/index.js +5 -0
  77. package/dist/tools/permission.js +82 -16
  78. package/dist/tools/polyglot.js +126 -0
  79. package/dist/tools/sandbox.js +38 -13
  80. package/dist/tools/search.js +9 -2
  81. package/dist/tools/task.js +22 -2
  82. package/dist/tools/timeout.js +7 -5
  83. package/dist/tools/web-fetch-tool.js +33 -0
  84. package/dist/turn-retrieval.js +83 -0
  85. package/dist/ui/app.js +874 -35
  86. package/dist/ui/banner.js +78 -4
  87. package/dist/ui/markdown.js +122 -0
  88. package/dist/ui/overlay.js +496 -0
  89. package/dist/ui/queue.js +23 -0
  90. package/dist/ui/render.js +30 -2
  91. package/dist/ui/session-panel.js +115 -0
  92. package/dist/ui/setup-providers.js +40 -0
  93. package/dist/ui/setup.js +163 -50
  94. package/dist/ui/status.js +142 -0
  95. package/dist/ui/thinking-panel.js +36 -0
  96. package/dist/ui/tool-trail.js +97 -0
  97. package/dist/ui/transcript.js +26 -0
  98. package/dist/ui/useBusyElapsed.js +19 -0
  99. package/dist/ui/useEditor.js +144 -5
  100. package/dist/ui/useGitBranch.js +57 -0
  101. package/dist/update.js +32 -6
  102. package/dist/usage-cli.js +160 -0
  103. package/dist/usage-ledger.js +169 -0
  104. package/dist/web-fetch.js +637 -0
  105. package/dist/web-surface.js +190 -0
  106. package/package.json +4 -3
  107. package/scripts/postinstall.mjs +4 -4
  108. package/second-brain/Projects/_Index.md +17 -4
  109. package/second-brain/Projects/sanook-cli/_Index.md +7 -3
  110. package/second-brain/Projects/sanook-cli/context.md +35 -0
  111. package/second-brain/Projects/sanook-cli/current-state.md +32 -0
  112. package/second-brain/Projects/sanook-cli/overview.md +41 -0
  113. package/second-brain/Projects/sanook-cli/repo.md +34 -0
  114. package/second-brain/Projects/sanook-cli/second-brain-feature-roadmap.md +52 -11
  115. package/second-brain/Research/2026-06-18-hermes-tui-parity-map.md +129 -0
  116. package/second-brain/Research/2026-06-19-hermes-python-architecture-for-sanook.md +49 -0
  117. package/second-brain/Research/2026-06-19-terminal-ui-brand-research.md +52 -0
  118. package/second-brain/Research/_Index.md +2 -0
  119. package/second-brain/Shared/Operating-State/current-state.md +14 -23
  120. package/second-brain/Shared/Tech-Standards/_Index.md +2 -0
  121. package/second-brain/Shared/Tech-Standards/polyglot-runtime-strategy.md +46 -0
  122. package/second-brain/Shared/Tech-Standards/web-search-grounding-policy.md +70 -0
  123. package/second-brain/Templates/project-workspace/_Index.md +31 -0
  124. package/second-brain/Templates/project-workspace/context.md +28 -0
  125. package/second-brain/Templates/project-workspace/current-state.md +29 -0
  126. package/second-brain/Templates/project-workspace/overview.md +39 -0
  127. package/second-brain/Templates/project-workspace/repo.md +33 -0
package/dist/ui/app.js CHANGED
@@ -1,26 +1,49 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useState, useRef } from 'react';
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
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, Static, useApp, useInput } from 'ink';
6
- import { BUILTIN_COMMANDS, expandCustomCommand, loadCustomCommands, parseCommand, parseSlashInvocation, } from '../commands.js';
5
+ import { Box, Text, useApp, useInput, useStdout } from 'ink';
6
+ import { homedir } from 'node:os';
7
+ import { BUILTIN_COMMANDS, HELP_TEXT, expandCustomCommand, loadCustomCommands, parseCommand, parseSlashInvocation, } from '../commands.js';
7
8
  import { runAgent } from '../loop.js';
8
- import { saveSession, newSessionId } from '../session.js';
9
+ import { finalizeReplSession, formatFinalizeMessage } from '../session-brain.js';
10
+ import { saveSession, newSessionId, listSessions, removeSession, renameSession } from '../session.js';
11
+ import { TOOL_CATALOG } from '../tool-catalog.js';
9
12
  import { getBrainPath, appendBrainWorklog } from '../memory.js';
10
13
  import { autoCompact, estimateTokens, summarizeCompact } from '../compaction.js';
11
14
  import { makeSummarizer } from '../summarize.js';
12
15
  import { agentTuning, patchGlobalConfig } from '../config.js';
13
16
  import { snapshotWorkTree, restoreWorkTree } from '../checkpoint.js';
14
17
  import { renderInsights } from '../insights.js';
18
+ import { loadMcpHubEntries } from '../mcp-hub.js';
19
+ import { probeMcpServer } from '../mcp.js';
20
+ import { filterModelPickerOptions, initialModelPickerIndex, modelPickerOptions, modelProviderEntries, } from '../model-picker.js';
21
+ import { clampCompletionIndex, completionForInput, completionReplaceValue } from '../slash-completion.js';
22
+ import { loadSkills } from '../skills.js';
23
+ import { copyTextToClipboard } from '../clipboard.js';
15
24
  import { useEditor } from './useEditor.js';
25
+ import { useBusyElapsedSeconds } from './useBusyElapsed.js';
26
+ import { useGitBranch } from './useGitBranch.js';
16
27
  import { loadHistory, appendHistory } from './history.js';
17
28
  import { expandMentions } from './mentions.js';
18
29
  import { BRAND } from '../brand.js';
30
+ import { backgroundTaskRunningCount, listBackgroundTasks } from '../tools/task.js';
19
31
  import { Banner } from './banner.js';
32
+ import { CompletionOverlay, FloatingOverlay, firstUserSummary } from './overlay.js';
33
+ import { clampQueueActiveIndex, compactPreview, getQueueWindow, queueActiveIndexAfterDelete } from './queue.js';
34
+ import { MarkdownText, StreamingMarkdownText } from './markdown.js';
35
+ import { SessionPanel } from './session-panel.js';
36
+ import { getTranscriptWindow, transcriptScrollStep, transcriptWindowSize } from './transcript.js';
37
+ import { footerStatus } from './status.js';
38
+ import { thinkingPanelLines, snapshotThinking } from './thinking-panel.js';
39
+ import { toolTrailLines, updateToolTrailOnEvent } from './tool-trail.js';
20
40
  const execFileP = promisify(execFile);
21
41
  const PRE_TURN_COMPACT_TOKENS = 100_000; // session ยาวมากเท่านั้นถึง summarize ก่อน turn (mode summarize)
42
+ const startupCount = (value) => value === 'checking' ? 'checking' : value.count ? `${value.count}` : 'none';
43
+ const shortSignal = (value, max = 18) => value.length > max ? `…${value.slice(Math.max(0, value.length - max + 1))}` : value;
22
44
  export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = 'ask', initialHistory, initialNote }) {
23
45
  const { exit } = useApp();
46
+ const { stdout } = useStdout();
24
47
  const [history, setHistory] = useState(() => {
25
48
  const seed = [];
26
49
  if (initialNote)
@@ -30,37 +53,235 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
30
53
  return seed;
31
54
  });
32
55
  const [streaming, setStreaming] = useState('');
56
+ const [thinking, setThinking] = useState('');
57
+ const [agentStatus, setAgentStatus] = useState('');
58
+ const [toolTrail, setToolTrail] = useState([]);
33
59
  const [busy, setBusy] = useState(false);
34
60
  const [model, setModel] = useState(initialModel);
35
61
  const [approvalReq, setApprovalReq] = useState(null);
62
+ const [overlay, setOverlay] = useState(null);
63
+ const [completionIndex, setCompletionIndex] = useState(0);
64
+ const [historyResetKey, setHistoryResetKey] = useState(0);
65
+ const [queueActiveIndex, setQueueActiveIndex] = useState(null);
66
+ const [toolTrailMode, setToolTrailModeState] = useState('expanded');
67
+ const [thinkingMode, setThinkingMode] = useState('collapsed');
68
+ const [contextCompression, setContextCompression] = useState();
69
+ const [transcriptScroll, setTranscriptScroll] = useState(0);
36
70
  const idRef = useRef(0);
37
71
  const lastCost = useRef('');
72
+ const nextToolTrailId = useRef(0);
73
+ const toolTrailRef = useRef([]);
74
+ const toolTrailModeRef = useRef('expanded');
75
+ const thinkingRef = useRef('');
38
76
  const msgsRef = useRef(initialHistory ?? []); // conversation จริงสำหรับ LLM (สะสมข้ามรอบ)
39
77
  const sessionId = useRef(newSessionId());
40
78
  const sessionCreated = useRef(new Date().toISOString());
79
+ const exitingRef = useRef(false);
41
80
  const approvalResolve = useRef(null);
42
81
  const replHistory = useRef(loadHistory()); // prompt เก่า (persist) สำหรับ ↑/↓
43
82
  const checkpoints = useRef([]);
44
83
  const lastRun = useRef(null);
45
84
  const editor = useEditor(replHistory.current);
85
+ const cwd = process.cwd();
86
+ const [startupReadiness, setStartupReadiness] = useState({
87
+ brain: 'checking',
88
+ mcp: 'checking',
89
+ skills: 'checking',
90
+ });
46
91
  // real-time steering: หยุด turn ที่กำลังรัน (abort) + คิวข้อความที่พิมพ์ระหว่าง busy
47
92
  const abortRef = useRef(null);
48
93
  const queueRef = useRef([]);
49
94
  const [queued, setQueued] = useState([]);
95
+ const [bgTaskCount, setBgTaskCount] = useState(0);
50
96
  const enqueue = (msg) => {
51
97
  queueRef.current.push(msg);
52
98
  setQueued([...queueRef.current]);
99
+ setQueueActiveIndex((index) => clampQueueActiveIndex(index, queueRef.current.length));
53
100
  };
54
101
  const dequeue = () => {
55
102
  const m = queueRef.current.shift();
56
103
  setQueued([...queueRef.current]);
104
+ setQueueActiveIndex((index) => {
105
+ if (!queueRef.current.length)
106
+ return null;
107
+ if (index === null)
108
+ return 0;
109
+ return clampQueueActiveIndex(index - 1, queueRef.current.length);
110
+ });
57
111
  return m;
58
112
  };
59
113
  const clearQueue = () => {
60
114
  queueRef.current = [];
61
115
  setQueued([]);
116
+ setQueueActiveIndex(null);
117
+ };
118
+ const moveQueueActive = (delta) => {
119
+ setQueueActiveIndex((index) => {
120
+ const active = clampQueueActiveIndex(index, queueRef.current.length);
121
+ return active === null ? null : clampQueueActiveIndex(active + delta, queueRef.current.length);
122
+ });
123
+ };
124
+ const removeActiveQueued = () => {
125
+ const length = queueRef.current.length;
126
+ const active = clampQueueActiveIndex(queueActiveIndex, length);
127
+ if (active === null)
128
+ return undefined;
129
+ const [removed] = queueRef.current.splice(active, 1);
130
+ setQueued([...queueRef.current]);
131
+ setQueueActiveIndex(queueActiveIndexAfterDelete(active, length));
132
+ return removed;
133
+ };
134
+ const resetLiveToolTrail = () => {
135
+ nextToolTrailId.current = 0;
136
+ toolTrailRef.current = [];
137
+ setToolTrail([]);
138
+ };
139
+ const resetLiveThinking = () => {
140
+ thinkingRef.current = '';
141
+ setThinking('');
142
+ };
143
+ const setToolTrailMode = (mode) => {
144
+ toolTrailModeRef.current = mode;
145
+ setToolTrailModeState(mode);
146
+ // NOTE: this remount is load-bearing — the transcript lives in <Static>, which freezes already-
147
+ // emitted turns; bumping the key is what re-renders past turns in the new mode. (Cost: a full
148
+ // scrollback re-emit on toggle — a known <Static> trade-off, not removable without a rewrite.)
149
+ setHistoryResetKey((key) => key + 1);
150
+ };
151
+ const changeToolTrailMode = (mode) => {
152
+ const next = mode ?? (toolTrailModeRef.current === 'expanded' ? 'compact' : 'expanded');
153
+ setToolTrailMode(next);
154
+ return next;
155
+ };
156
+ const noteToolTrailMode = (mode) => {
157
+ addTurn('system', `tool trail → ${mode} (${mode === 'compact' ? 'สรุปสั้น' : mode === 'hidden' ? 'ซ่อน' : 'แสดงรายละเอียด'})`);
158
+ };
159
+ const snapshotToolTrail = () => toolTrailRef.current.length ? toolTrailRef.current.map((item) => ({ ...item })) : undefined;
160
+ const applyDetailsMode = (section, mode) => {
161
+ if (!section || !mode)
162
+ return;
163
+ if (section === 'thinking') {
164
+ setThinkingMode(mode);
165
+ setHistoryResetKey((key) => key + 1); // remount needed to restyle frozen <Static> turns (see setToolTrailMode)
166
+ addTurn('system', `details thinking → ${mode}`);
167
+ return;
168
+ }
169
+ const nextToolMode = mode === 'expanded' ? 'expanded' : mode === 'hidden' ? 'hidden' : 'compact';
170
+ noteToolTrailMode(changeToolTrailMode(nextToolMode));
171
+ };
172
+ const addTurn = (role, text, extras) => {
173
+ setTranscriptScroll(0);
174
+ setHistory((h) => [
175
+ ...h,
176
+ {
177
+ id: idRef.current++,
178
+ role,
179
+ thinking: extras?.thinking,
180
+ text,
181
+ toolTrail: extras?.toolTrail?.length ? extras.toolTrail.map((item) => ({ ...item })) : undefined,
182
+ },
183
+ ]);
184
+ };
185
+ const recordToolTrailEvent = (event) => {
186
+ if (event.type !== 'tool-call' && event.type !== 'tool-result' && event.type !== 'error')
187
+ return;
188
+ const type = event.type === 'tool-call' ? 'tool-call' : event.type === 'tool-result' ? 'tool-result' : 'error';
189
+ const next = updateToolTrailOnEvent(toolTrailRef.current, { detail: event.detail, text: event.text, tool: event.tool, type }, nextToolTrailId.current);
190
+ nextToolTrailId.current = next.nextId;
191
+ toolTrailRef.current = next.items;
192
+ setToolTrail(next.items);
193
+ };
194
+ const replaceHistory = (next) => {
195
+ setHistoryResetKey((key) => key + 1);
196
+ setTranscriptScroll(0);
197
+ setHistory(next);
198
+ };
199
+ const filterHistory = (predicate) => {
200
+ setHistoryResetKey((key) => key + 1);
201
+ setTranscriptScroll(0);
202
+ setHistory((h) => h.filter(predicate));
203
+ };
204
+ const gitBranch = useGitBranch(cwd);
205
+ const busyElapsedSeconds = useBusyElapsedSeconds(busy);
206
+ const columns = Math.max(20, stdout?.columns ?? 80);
207
+ const pagerPageSize = Math.max(5, Math.min(18, (stdout?.rows ?? 24) - 10));
208
+ const completion = !overlay && !busy ? completionForInput(editor.value, cwd) : { items: [], replaceFrom: 0 };
209
+ const completions = completion.items;
210
+ const selectedCompletion = clampCompletionIndex(completionIndex, completions.length);
211
+ useEffect(() => {
212
+ let alive = true;
213
+ void agentTuning()
214
+ .then((tuning) => {
215
+ if (alive)
216
+ setContextCompression(tuning.contextCompression);
217
+ })
218
+ .catch(() => {
219
+ if (alive)
220
+ setContextCompression(undefined);
221
+ });
222
+ return () => {
223
+ alive = false;
224
+ };
225
+ }, []);
226
+ useEffect(() => {
227
+ let alive = true;
228
+ void Promise.allSettled([getBrainPath(), loadMcpHubEntries(cwd), loadSkills(cwd)]).then(([brain, mcp, skills]) => {
229
+ if (!alive)
230
+ return;
231
+ setStartupReadiness({
232
+ brain: brain.status === 'fulfilled' && brain.value ? 'ready' : 'missing',
233
+ mcp: mcp.status === 'fulfilled'
234
+ ? { count: mcp.value.entries.length, names: mcp.value.entries.map((entry) => entry.name) }
235
+ : { count: 0, names: [] },
236
+ skills: skills.status === 'fulfilled'
237
+ ? { count: skills.value.length, names: skills.value.map((skill) => skill.name) }
238
+ : { count: 0, names: [] },
239
+ });
240
+ });
241
+ return () => {
242
+ alive = false;
243
+ };
244
+ }, [cwd]);
245
+ useEffect(() => {
246
+ const refresh = () => setBgTaskCount(backgroundTaskRunningCount());
247
+ refresh();
248
+ const timer = setInterval(refresh, 2000);
249
+ return () => clearInterval(timer);
250
+ }, []);
251
+ const bannerSignals = [
252
+ { label: 'brain', tone: startupReadiness.brain === 'ready' ? 'ready' : startupReadiness.brain === 'checking' ? 'muted' : 'warn', value: startupReadiness.brain },
253
+ { label: 'mcp', tone: startupReadiness.mcp === 'checking' ? 'muted' : startupReadiness.mcp.count ? 'ready' : 'warn', value: startupCount(startupReadiness.mcp) },
254
+ { label: 'skills', tone: startupReadiness.skills === 'checking' ? 'muted' : startupReadiness.skills.count ? 'ready' : 'warn', value: startupCount(startupReadiness.skills) },
255
+ ...(gitBranch ? [{ label: 'git', tone: 'ready', value: shortSignal(gitBranch) }] : []),
256
+ ];
257
+ const applyCompletion = () => {
258
+ const next = completionReplaceValue(editor.value, completions[selectedCompletion], completion.replaceFrom);
259
+ if (!next)
260
+ return false;
261
+ editor.setValue(next);
262
+ setCompletionIndex(0);
263
+ return true;
264
+ };
265
+ const requestExit = () => {
266
+ if (exitingRef.current)
267
+ return;
268
+ exitingRef.current = true;
269
+ void finalizeReplSession({
270
+ sessionId: sessionId.current,
271
+ sessionCreated: sessionCreated.current,
272
+ model,
273
+ cwd,
274
+ messages: msgsRef.current,
275
+ history: history.map((turn) => ({ role: turn.role, text: turn.text })),
276
+ })
277
+ .then((result) => {
278
+ const note = formatFinalizeMessage(result);
279
+ if (note)
280
+ process.stderr.write(`\n${note}\n`);
281
+ exit();
282
+ })
283
+ .catch(() => exit());
62
284
  };
63
- const addTurn = (role, text) => setHistory((h) => [...h, { id: idRef.current++, role, text }]);
64
285
  // /diff /undo — git-backed (execFile ไม่ผ่าน shell)
65
286
  async function runGit(args, label) {
66
287
  try {
@@ -76,7 +297,460 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
76
297
  approvalResolve.current = resolve;
77
298
  setApprovalReq({ tool, summary });
78
299
  });
300
+ const openModelPicker = () => {
301
+ const providers = modelProviderEntries();
302
+ const options = modelPickerOptions(model);
303
+ setOverlay({
304
+ kind: 'model',
305
+ phase: 'provider',
306
+ providers,
307
+ options,
308
+ selected: 0,
309
+ });
310
+ };
311
+ const openMcpHub = async () => {
312
+ try {
313
+ const state = await loadMcpHubEntries(process.cwd());
314
+ setOverlay({ detail: false, kind: 'mcp', notes: state.notes, selected: 0, servers: state.entries });
315
+ }
316
+ catch (e) {
317
+ addTurn('system', `mcp: ${e.message}`);
318
+ }
319
+ };
320
+ const moveMcpHub = (delta) => {
321
+ setOverlay((current) => {
322
+ if (current?.kind !== 'mcp' || current.detail)
323
+ return current;
324
+ const last = Math.max(0, current.servers.length - 1);
325
+ return { ...current, probe: undefined, selected: Math.max(0, Math.min(last, current.selected + delta)), toolSelected: 0 };
326
+ });
327
+ };
328
+ const moveMcpToolCatalog = (delta) => {
329
+ setOverlay((current) => {
330
+ if (current?.kind !== 'mcp' || !current.detail)
331
+ return current;
332
+ const tools = current.probe?.status === 'pass' ? (current.probe.tools ?? []) : [];
333
+ if (!tools.length)
334
+ return current;
335
+ const last = tools.length - 1;
336
+ const selected = Math.max(0, Math.min(last, (current.toolSelected ?? 0) + delta));
337
+ return { ...current, toolSelected: selected };
338
+ });
339
+ };
340
+ const testMcpServerFromOverlay = (current) => {
341
+ if (current.kind !== 'mcp')
342
+ return;
343
+ const server = current.servers[current.selected];
344
+ if (!server)
345
+ return;
346
+ setOverlay({ ...current, detail: true, probe: { serverName: server.name, status: 'running' }, toolSelected: 0 });
347
+ void probeMcpServer(server.config, 8_000)
348
+ .then((result) => {
349
+ setOverlay((latest) => {
350
+ if (latest?.kind !== 'mcp' || !latest.detail || latest.probe?.serverName !== server.name)
351
+ return latest;
352
+ return {
353
+ ...latest,
354
+ detail: true,
355
+ probe: result.ok
356
+ ? { serverName: server.name, status: 'pass', tools: result.tools, transport: result.transport }
357
+ : {
358
+ error: result.error ?? 'unknown error',
359
+ serverName: server.name,
360
+ status: 'fail',
361
+ transport: result.transport,
362
+ },
363
+ toolSelected: 0,
364
+ };
365
+ });
366
+ })
367
+ .catch((e) => {
368
+ setOverlay((latest) => {
369
+ if (latest?.kind !== 'mcp' || !latest.detail || latest.probe?.serverName !== server.name)
370
+ return latest;
371
+ return {
372
+ ...latest,
373
+ detail: true,
374
+ probe: { error: e.message, serverName: server.name, status: 'fail' },
375
+ toolSelected: 0,
376
+ };
377
+ });
378
+ });
379
+ };
380
+ const moveModelPicker = (delta) => {
381
+ setOverlay((current) => {
382
+ if (current?.kind !== 'model')
383
+ return current;
384
+ const list = current.phase === 'provider' ? current.providers : current.options;
385
+ const last = Math.max(0, list.length - 1);
386
+ return { ...current, selected: Math.max(0, Math.min(last, current.selected + delta)) };
387
+ });
388
+ };
389
+ const selectModelFromOverlay = (current) => {
390
+ if (current.kind !== 'model')
391
+ return;
392
+ if (current.phase === 'provider') {
393
+ const provider = current.providers[current.selected];
394
+ if (!provider)
395
+ return;
396
+ const options = filterModelPickerOptions(modelPickerOptions(model), provider.id);
397
+ setOverlay({
398
+ kind: 'model',
399
+ phase: 'model',
400
+ providerFilter: provider.id,
401
+ providers: current.providers,
402
+ options,
403
+ selected: initialModelPickerIndex(options),
404
+ });
405
+ return;
406
+ }
407
+ const selectedSpec = current.options[current.selected]?.spec ?? '';
408
+ setOverlay(null);
409
+ if (!selectedSpec)
410
+ return;
411
+ const result = parseCommand(`/model ${selectedSpec}`, { model, costSummary: lastCost.current });
412
+ if (result.modelChange)
413
+ setModel(result.modelChange);
414
+ if (result.message)
415
+ addTurn('system', result.message);
416
+ };
417
+ const openHelpPager = (text = HELP_TEXT) => {
418
+ setOverlay({ kind: 'pager', lines: text.split('\n'), offset: 0, title: 'Sanook help' });
419
+ };
420
+ const movePager = (delta) => {
421
+ setOverlay((current) => {
422
+ if (current?.kind !== 'pager')
423
+ return current;
424
+ const max = Math.max(0, current.lines.length - pagerPageSize);
425
+ const step = delta === 'top' ? -current.lines.length : delta === 'bottom' ? current.lines.length : delta;
426
+ const next = Math.max(0, Math.min(current.offset + step, max));
427
+ return next === current.offset ? current : { ...current, offset: next };
428
+ });
429
+ };
430
+ const pagePagerForward = () => {
431
+ setOverlay((current) => {
432
+ if (current?.kind !== 'pager')
433
+ return current;
434
+ const max = Math.max(0, current.lines.length - pagerPageSize);
435
+ if (current.offset >= max)
436
+ return null;
437
+ return { ...current, offset: Math.min(current.offset + pagerPageSize, max) };
438
+ });
439
+ };
440
+ const openSkillsHub = async () => {
441
+ try {
442
+ const skills = (await loadSkills()).sort((a, b) => a.name.localeCompare(b.name));
443
+ setOverlay({ detail: false, kind: 'skills', selected: 0, skills });
444
+ }
445
+ catch (e) {
446
+ addTurn('system', `skills: ${e.message}`);
447
+ }
448
+ };
449
+ const openToolsHub = () => {
450
+ setOverlay({ detail: false, kind: 'tools', selected: 0, tools: TOOL_CATALOG });
451
+ };
452
+ const openTasksHub = () => {
453
+ const tasks = listBackgroundTasks().sort((a, b) => b.startedMs - a.startedMs);
454
+ setOverlay({ detail: false, kind: 'tasks', selected: 0, tasks });
455
+ };
456
+ const moveTasksHub = (delta) => {
457
+ setOverlay((current) => {
458
+ if (current?.kind !== 'tasks' || current.detail)
459
+ return current;
460
+ const last = Math.max(0, current.tasks.length - 1);
461
+ return { ...current, selected: Math.max(0, Math.min(last, current.selected + delta)) };
462
+ });
463
+ };
464
+ const copyLatestAssistant = async () => {
465
+ const latest = [...history].reverse().find((turn) => turn.role === 'assistant' && turn.text.trim());
466
+ if (!latest) {
467
+ addTurn('system', 'copy: ยังไม่มีคำตอบ assistant ให้คัดลอก');
468
+ return;
469
+ }
470
+ try {
471
+ const result = await copyTextToClipboard(latest.text, { writeOsc52: (sequence) => stdout?.write(sequence) });
472
+ addTurn('system', `copy: copied latest assistant (${latest.text.length} chars) via ${result.detail}`);
473
+ }
474
+ catch (e) {
475
+ addTurn('system', `copy: ${e.message}`);
476
+ }
477
+ };
478
+ const moveToolsHub = (delta) => {
479
+ setOverlay((current) => {
480
+ if (current?.kind !== 'tools' || current.detail)
481
+ return current;
482
+ const last = Math.max(0, current.tools.length - 1);
483
+ return { ...current, selected: Math.max(0, Math.min(last, current.selected + delta)) };
484
+ });
485
+ };
486
+ const moveSkillsHub = (delta) => {
487
+ setOverlay((current) => {
488
+ if (current?.kind !== 'skills' || current.detail)
489
+ return current;
490
+ const last = Math.max(0, current.skills.length - 1);
491
+ return { ...current, selected: Math.max(0, Math.min(last, current.selected + delta)) };
492
+ });
493
+ };
494
+ const openSessionsHub = async () => {
495
+ try {
496
+ const sessions = await listSessions({ cwd: null, limit: 20 });
497
+ setOverlay({ currentCwd: cwd, kind: 'sessions', selected: 0, sessions });
498
+ }
499
+ catch (e) {
500
+ addTurn('system', `sessions: ${e.message}`);
501
+ }
502
+ };
503
+ const moveSessionsHub = (delta) => {
504
+ setOverlay((current) => {
505
+ if (current?.kind !== 'sessions')
506
+ return current;
507
+ const last = Math.max(0, current.sessions.length - 1);
508
+ return { ...current, notice: undefined, pendingDeleteId: undefined, selected: Math.max(0, Math.min(last, current.selected + delta)) };
509
+ });
510
+ };
511
+ const inspectSessionFromOverlay = (current) => {
512
+ if (current.kind !== 'sessions' || !current.sessions[current.selected])
513
+ return;
514
+ setOverlay({ ...current, detail: true, notice: undefined, pendingDeleteId: undefined });
515
+ };
516
+ const resumeSessionFromOverlay = (current) => {
517
+ if (current.kind !== 'sessions')
518
+ return;
519
+ const session = current.sessions[current.selected];
520
+ setOverlay(null);
521
+ if (!session)
522
+ return;
523
+ restoreSession(session);
524
+ };
525
+ const restoreSession = (session) => {
526
+ msgsRef.current = session.messages;
527
+ checkpoints.current = [];
528
+ lastRun.current = null;
529
+ lastCost.current = '';
530
+ sessionId.current = session.id;
531
+ sessionCreated.current = session.created;
532
+ setModel(session.model);
533
+ resetLiveToolTrail();
534
+ resetLiveThinking();
535
+ const crossProject = session.cwd !== cwd;
536
+ const cwdNote = crossProject ? ` · cwd ${session.cwd.replace(homedir(), '~')}` : '';
537
+ replaceHistory([
538
+ {
539
+ id: idRef.current++,
540
+ role: 'system',
541
+ text: `↻ เปิด session ${session.id} (${session.messages.length} messages)${cwdNote}${crossProject ? ' · --continue-any' : ''}`,
542
+ },
543
+ ]);
544
+ };
545
+ const startSessionRename = (current) => {
546
+ if (current.kind !== 'sessions')
547
+ return;
548
+ const session = current.sessions[current.selected];
549
+ if (!session)
550
+ return;
551
+ const draft = session.title || firstUserSummary(session) || '';
552
+ setOverlay({
553
+ ...current,
554
+ detail: false,
555
+ notice: undefined,
556
+ pendingDeleteId: undefined,
557
+ renaming: draft,
558
+ });
559
+ };
560
+ const confirmSessionRename = async (current) => {
561
+ if (current.kind !== 'sessions' || current.renaming === undefined)
562
+ return;
563
+ const session = current.sessions[current.selected];
564
+ if (!session)
565
+ return;
566
+ const title = current.renaming.trim();
567
+ if (!title) {
568
+ setOverlay({ ...current, notice: 'rename: title cannot be empty' });
569
+ return;
570
+ }
571
+ try {
572
+ const updated = await renameSession(session.id, title);
573
+ if (!updated) {
574
+ setOverlay({ ...current, notice: `rename failed: ${session.id} not found` });
575
+ return;
576
+ }
577
+ const sessions = current.sessions.map((item) => (item.id === session.id ? updated : item));
578
+ setOverlay({
579
+ ...current,
580
+ notice: `renamed → ${title}`,
581
+ renaming: undefined,
582
+ sessions,
583
+ });
584
+ }
585
+ catch (e) {
586
+ setOverlay({ ...current, notice: `rename failed: ${e.message}` });
587
+ }
588
+ };
589
+ const deleteSessionFromOverlay = async (current) => {
590
+ if (current.kind !== 'sessions')
591
+ return;
592
+ const session = current.sessions[current.selected];
593
+ if (!session)
594
+ return;
595
+ if (current.pendingDeleteId !== session.id) {
596
+ setOverlay({ ...current, notice: `delete? press d again: ${session.id}`, pendingDeleteId: session.id });
597
+ return;
598
+ }
599
+ try {
600
+ const removed = await removeSession(session.id);
601
+ const sessions = current.sessions.filter((item) => item.id !== session.id);
602
+ const selected = Math.max(0, Math.min(current.selected, sessions.length - 1));
603
+ setOverlay({
604
+ detail: false,
605
+ kind: 'sessions',
606
+ notice: removed ? `deleted ${session.id}` : `already removed ${session.id}`,
607
+ selected,
608
+ sessions,
609
+ });
610
+ }
611
+ catch (e) {
612
+ setOverlay({ ...current, notice: `delete failed: ${e.message}`, pendingDeleteId: undefined });
613
+ }
614
+ };
79
615
  useInput((input, key) => {
616
+ if (overlay) {
617
+ if (overlay.kind === 'model') {
618
+ if (input === 'q' || input === 'Q')
619
+ setOverlay(null);
620
+ else if (key.escape) {
621
+ if (overlay.phase === 'model') {
622
+ setOverlay({ ...overlay, phase: 'provider', providerFilter: undefined, selected: 0 });
623
+ }
624
+ else
625
+ setOverlay(null);
626
+ }
627
+ else if (key.return)
628
+ selectModelFromOverlay(overlay);
629
+ else if (key.downArrow || input === 'j' || input === 'J')
630
+ moveModelPicker(1);
631
+ else if (key.upArrow || input === 'k' || input === 'K')
632
+ moveModelPicker(-1);
633
+ return;
634
+ }
635
+ if (overlay.kind === 'mcp') {
636
+ if (input === 'q' || input === 'Q')
637
+ setOverlay(null);
638
+ else if (input === 't' || input === 'T')
639
+ testMcpServerFromOverlay(overlay);
640
+ else if (overlay.detail && (key.escape || key.return))
641
+ setOverlay({ ...overlay, detail: false, toolSelected: 0 });
642
+ else if (key.escape)
643
+ setOverlay(null);
644
+ else if (key.return && overlay.servers.length)
645
+ setOverlay({ ...overlay, detail: true, toolSelected: 0 });
646
+ else if (overlay.detail && (key.downArrow || input === 'j' || input === 'J'))
647
+ moveMcpToolCatalog(1);
648
+ else if (overlay.detail && (key.upArrow || input === 'k' || input === 'K'))
649
+ moveMcpToolCatalog(-1);
650
+ else if (key.downArrow || input === 'j' || input === 'J')
651
+ moveMcpHub(1);
652
+ else if (key.upArrow || input === 'k' || input === 'K')
653
+ moveMcpHub(-1);
654
+ return;
655
+ }
656
+ if (overlay.kind === 'pager') {
657
+ if (key.escape || input === 'q' || input === 'Q')
658
+ setOverlay(null);
659
+ else if (key.upArrow || input === 'k' || input === 'K')
660
+ movePager(-1);
661
+ else if (key.downArrow || input === 'j' || input === 'J')
662
+ movePager(1);
663
+ else if (key.pageUp || input === 'b' || input === 'B')
664
+ movePager(-pagerPageSize);
665
+ else if (input === 'g')
666
+ movePager('top');
667
+ else if (input === 'G')
668
+ movePager('bottom');
669
+ else if (key.return || key.pageDown || input === ' ')
670
+ pagePagerForward();
671
+ return;
672
+ }
673
+ if (overlay.kind === 'skills') {
674
+ if (input === 'q' || input === 'Q')
675
+ setOverlay(null);
676
+ else if (overlay.detail && (key.escape || key.return))
677
+ setOverlay({ ...overlay, detail: false });
678
+ else if (key.escape)
679
+ setOverlay(null);
680
+ else if (key.return && overlay.skills.length)
681
+ setOverlay({ ...overlay, detail: true });
682
+ else if (key.downArrow || input === 'j' || input === 'J')
683
+ moveSkillsHub(1);
684
+ else if (key.upArrow || input === 'k' || input === 'K')
685
+ moveSkillsHub(-1);
686
+ return;
687
+ }
688
+ if (overlay.kind === 'sessions') {
689
+ if (overlay.renaming !== undefined) {
690
+ if (key.escape)
691
+ setOverlay({ ...overlay, notice: undefined, renaming: undefined });
692
+ else if (key.return)
693
+ void confirmSessionRename(overlay);
694
+ else if (key.backspace || key.delete)
695
+ setOverlay({ ...overlay, renaming: overlay.renaming.slice(0, -1) });
696
+ else if (input && !key.ctrl && !key.meta)
697
+ setOverlay({ ...overlay, renaming: overlay.renaming + input });
698
+ return;
699
+ }
700
+ if (input === 'q' || input === 'Q')
701
+ setOverlay(null);
702
+ else if (overlay.detail && key.escape)
703
+ setOverlay({ ...overlay, detail: false, notice: undefined, pendingDeleteId: undefined });
704
+ else if (key.escape)
705
+ setOverlay(null);
706
+ else if (input === 'd' || input === 'D')
707
+ void deleteSessionFromOverlay(overlay);
708
+ else if (input === 'r' || input === 'R')
709
+ startSessionRename(overlay);
710
+ else if (input === 'i' || input === 'I')
711
+ inspectSessionFromOverlay(overlay);
712
+ else if (key.return)
713
+ resumeSessionFromOverlay(overlay);
714
+ else if (!overlay.detail && (key.downArrow || input === 'j' || input === 'J'))
715
+ moveSessionsHub(1);
716
+ else if (!overlay.detail && (key.upArrow || input === 'k' || input === 'K'))
717
+ moveSessionsHub(-1);
718
+ return;
719
+ }
720
+ if (overlay.kind === 'tools') {
721
+ if (input === 'q' || input === 'Q')
722
+ setOverlay(null);
723
+ else if (overlay.detail && (key.escape || key.return))
724
+ setOverlay({ ...overlay, detail: false });
725
+ else if (key.escape)
726
+ setOverlay(null);
727
+ else if (key.return && overlay.tools.length)
728
+ setOverlay({ ...overlay, detail: true });
729
+ else if (key.downArrow || input === 'j' || input === 'J')
730
+ moveToolsHub(1);
731
+ else if (key.upArrow || input === 'k' || input === 'K')
732
+ moveToolsHub(-1);
733
+ return;
734
+ }
735
+ if (overlay.kind === 'tasks') {
736
+ if (input === 'q' || input === 'Q')
737
+ setOverlay(null);
738
+ else if (overlay.detail && (key.escape || key.return))
739
+ setOverlay({ ...overlay, detail: false, tasks: listBackgroundTasks().sort((a, b) => b.startedMs - a.startedMs) });
740
+ else if (key.escape)
741
+ setOverlay(null);
742
+ else if (key.return && overlay.tasks.length)
743
+ setOverlay({ ...overlay, detail: true });
744
+ else if (key.downArrow || input === 'j' || input === 'J')
745
+ moveTasksHub(1);
746
+ else if (key.upArrow || input === 'k' || input === 'K')
747
+ moveTasksHub(-1);
748
+ return;
749
+ }
750
+ if (key.escape || key.return || input === 'q' || input === 'Q')
751
+ setOverlay(null);
752
+ return;
753
+ }
80
754
  // มี approval ค้าง → จับ y/n ก่อน (แม้ agent กำลังรัน/busy)
81
755
  if (approvalReq) {
82
756
  if (input === 'y' || input === 'Y' || key.return) {
@@ -96,10 +770,23 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
96
770
  clearQueue();
97
771
  return;
98
772
  }
773
+ if (key.ctrl && input === 't') {
774
+ noteToolTrailMode(changeToolTrailMode());
775
+ return;
776
+ }
777
+ if (key.ctrl && input === 'x') {
778
+ removeActiveQueued();
779
+ return;
780
+ }
781
+ if (!editor.value && queueRef.current.length && (key.upArrow || key.downArrow)) {
782
+ moveQueueActive(key.upArrow ? -1 : 1);
783
+ return;
784
+ }
99
785
  // พิมพ์ระหว่าง busy ได้ — Enter = ต่อคิว (รันอัตโนมัติหลัง turn นี้จบ)
100
786
  const a = editor.handleKey(input, key);
101
787
  if (a === 'submit') {
102
788
  const v = editor.value.trim();
789
+ const expanded = editor.expandValue(v).trim();
103
790
  editor.reset();
104
791
  const slash = parseSlashInvocation(v);
105
792
  if (slash?.name === 'stop') {
@@ -108,11 +795,44 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
108
795
  clearQueue();
109
796
  return;
110
797
  }
111
- if (v)
112
- enqueue(v);
798
+ if (expanded)
799
+ enqueue(expanded);
800
+ }
801
+ return;
802
+ }
803
+ if (completions.length) {
804
+ if (key.upArrow) {
805
+ setCompletionIndex((index) => clampCompletionIndex(index - 1, completions.length));
806
+ return;
807
+ }
808
+ if (key.downArrow) {
809
+ setCompletionIndex((index) => clampCompletionIndex(index + 1, completions.length));
810
+ return;
811
+ }
812
+ if (key.tab || key.return) {
813
+ if (applyCompletion())
814
+ return;
113
815
  }
816
+ }
817
+ if (key.ctrl && input === 't') {
818
+ noteToolTrailMode(changeToolTrailMode());
114
819
  return;
115
820
  }
821
+ const transcriptLimit = transcriptWindowSize(stdout?.rows);
822
+ const transcriptStep = transcriptScrollStep(transcriptLimit);
823
+ if (history.length > transcriptLimit) {
824
+ if (key.pageUp || (key.ctrl && input === 'u')) {
825
+ setTranscriptScroll((scroll) => {
826
+ const max = Math.max(0, history.length - transcriptLimit);
827
+ return Math.min(max, scroll + transcriptStep);
828
+ });
829
+ return;
830
+ }
831
+ if (key.pageDown || (key.ctrl && input === 'd')) {
832
+ setTranscriptScroll((scroll) => Math.max(0, scroll - transcriptStep));
833
+ return;
834
+ }
835
+ }
116
836
  const action = editor.handleKey(input, key);
117
837
  if (action === 'submit')
118
838
  void submit(editor.value);
@@ -120,7 +840,7 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
120
840
  if (editor.value)
121
841
  editor.reset(); // Ctrl+C ครั้งแรก = ล้างบรรทัด, ว่างแล้ว = ออก
122
842
  else
123
- exit();
843
+ requestExit();
124
844
  }
125
845
  });
126
846
  /** ย้อน 1 turn — คืนไฟล์ (git, recoverable) + ตัดบทสนทนากลับ */
@@ -141,7 +861,7 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
141
861
  }
142
862
  msgsRef.current = msgsRef.current.slice(0, cp.msgLen);
143
863
  lastRun.current = null;
144
- setHistory((h) => h.filter((t) => t.id < cp.turnId));
864
+ filterHistory((t) => t.id < cp.turnId);
145
865
  addTurn('system', `↩ ย้อนกลับ 1 turn${note}`);
146
866
  }
147
867
  async function retryLastTurn() {
@@ -153,7 +873,7 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
153
873
  }
154
874
  msgsRef.current = msgsRef.current.slice(0, previous.msgLen);
155
875
  checkpoints.current = checkpoints.current.filter((cp) => cp.turnId < previous.turnId);
156
- setHistory((h) => h.filter((t) => t.id < previous.turnId));
876
+ filterHistory((t) => t.id < previous.turnId);
157
877
  const mark = { turnId: idRef.current, msgLen: previous.msgLen };
158
878
  const preview = previous.userText.length > 120 ? `${previous.userText.slice(0, 117)}...` : previous.userText;
159
879
  addTurn('user', '/retry');
@@ -168,6 +888,8 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
168
888
  return;
169
889
  }
170
890
  const tuning = await agentTuning().catch(() => null);
891
+ if (tuning?.contextCompression)
892
+ setContextCompression(tuning.contextCompression);
171
893
  if (tuning?.compaction === 'summarize') {
172
894
  addTurn('system', '⏳ กำลังย่อ context ด้วย model ถูก…');
173
895
  msgsRef.current = await summarizeCompact(msgsRef.current, targetTokens, makeSummarizer(model, tuning.summaryModel), 20).catch(() => autoCompact(msgsRef.current, targetTokens, 20));
@@ -179,13 +901,14 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
179
901
  }
180
902
  }
181
903
  async function submit(raw) {
182
- const text = raw.trim();
904
+ const displayText = raw.trim();
905
+ const text = editor.expandValue(displayText).trim();
183
906
  editor.reset();
184
- if (!text)
907
+ if (!displayText)
185
908
  return;
186
- appendHistory(text, replHistory.current[replHistory.current.length - 1]);
187
- replHistory.current.push(text);
188
- const slash = parseSlashInvocation(text);
909
+ appendHistory(displayText, replHistory.current[replHistory.current.length - 1]);
910
+ replHistory.current.push(displayText);
911
+ const slash = parseSlashInvocation(displayText);
189
912
  if (slash) {
190
913
  if (slash.name === 'rewind') {
191
914
  await rewind();
@@ -196,31 +919,38 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
196
919
  if (custom) {
197
920
  const expanded = expandCustomCommand(custom, slash.args);
198
921
  const mark = { turnId: idRef.current, msgLen: msgsRef.current.length };
199
- addTurn('user', text);
922
+ addTurn('user', displayText);
200
923
  if (!expanded.trim()) {
201
924
  addTurn('system', `custom command /${slash.name} ว่าง`);
202
925
  return;
203
926
  }
204
- await runAssistantTurn(expanded, [], mark, text);
927
+ await runAssistantTurn(expanded, [], mark, displayText);
205
928
  return;
206
929
  }
207
930
  }
208
931
  }
209
- const cmd = parseCommand(text, { model, costSummary: lastCost.current });
932
+ const cmd = parseCommand(displayText, { model, costSummary: lastCost.current });
210
933
  if (cmd.handled) {
211
- addTurn('user', text);
934
+ addTurn('user', displayText);
212
935
  if (cmd.action === 'quit')
213
- return exit();
936
+ return requestExit();
214
937
  if (cmd.action === 'clear') {
215
938
  msgsRef.current = [];
216
939
  checkpoints.current = [];
217
940
  lastRun.current = null;
218
- return setHistory([]);
941
+ setStreaming('');
942
+ resetLiveToolTrail();
943
+ replaceHistory([]);
944
+ return;
219
945
  }
220
946
  if (cmd.action === 'compact') {
221
947
  void compactHistory(40_000, 'บีบ context');
222
948
  return;
223
949
  }
950
+ if (cmd.action === 'copyLast') {
951
+ void copyLatestAssistant();
952
+ return;
953
+ }
224
954
  if (cmd.action === 'diff')
225
955
  return void runGit(['diff', '--stat'], 'diff');
226
956
  if (cmd.action === 'retry')
@@ -241,6 +971,46 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
241
971
  void runGit(['stash', 'push', '-u', '-m', BRAND.undoStashMessage], 'undo').then(() => addTurn('system', 'กู้คืน: git stash pop'));
242
972
  return;
243
973
  }
974
+ if (cmd.action === 'help') {
975
+ openHelpPager(cmd.message);
976
+ return;
977
+ }
978
+ if (cmd.action === 'mcpHub') {
979
+ void openMcpHub();
980
+ return;
981
+ }
982
+ if (cmd.action === 'hotkeys') {
983
+ setOverlay({ kind: 'hotkeys' });
984
+ return;
985
+ }
986
+ if (cmd.action === 'modelPicker') {
987
+ openModelPicker();
988
+ return;
989
+ }
990
+ if (cmd.action === 'skillsHub') {
991
+ void openSkillsHub();
992
+ return;
993
+ }
994
+ if (cmd.action === 'toolTrail') {
995
+ noteToolTrailMode(changeToolTrailMode(cmd.toolTrailMode));
996
+ return;
997
+ }
998
+ if (cmd.action === 'details') {
999
+ applyDetailsMode(cmd.detailSection, cmd.detailMode);
1000
+ return;
1001
+ }
1002
+ if (cmd.action === 'toolsHub') {
1003
+ openToolsHub();
1004
+ return;
1005
+ }
1006
+ if (cmd.action === 'sessionsHub') {
1007
+ void openSessionsHub();
1008
+ return;
1009
+ }
1010
+ if (cmd.action === 'tasksHub') {
1011
+ openTasksHub();
1012
+ return;
1013
+ }
244
1014
  if (cmd.modelChange)
245
1015
  setModel(cmd.modelChange);
246
1016
  if (cmd.message)
@@ -249,11 +1019,11 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
249
1019
  }
250
1020
  // prompt ปกติ → expand @mentions (inline ไฟล์ text + เก็บ path รูป)
251
1021
  const mark = { turnId: idRef.current, msgLen: msgsRef.current.length };
252
- addTurn('user', text);
1022
+ addTurn('user', displayText);
253
1023
  const { text: expanded, images, errors } = await expandMentions(text);
254
1024
  if (errors.length)
255
1025
  addTurn('system', `@mention: ${errors.join(' · ')}`);
256
- await runAssistantTurn(expanded, images, mark, text);
1026
+ await runAssistantTurn(expanded, images, mark, displayText);
257
1027
  }
258
1028
  async function runAssistantTurn(promptText, images, mark, userText = promptText) {
259
1029
  lastRun.current = { ...mark, userText, promptText, images };
@@ -261,6 +1031,8 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
261
1031
  // (mode truncate: ปล่อยให้ loop.ts ตัดต่อ-step เอา; ไม่บีบที่นี่ กัน latency)
262
1032
  if (estimateTokens(msgsRef.current) > PRE_TURN_COMPACT_TOKENS) {
263
1033
  const t = await agentTuning().catch(() => null);
1034
+ if (t?.contextCompression)
1035
+ setContextCompression(t.contextCompression);
264
1036
  if (t?.compaction === 'summarize') {
265
1037
  addTurn('system', '⏳ context ยาว — ย่ออัตโนมัติก่อนรอบนี้…');
266
1038
  msgsRef.current = await summarizeCompact(msgsRef.current, PRE_TURN_COMPACT_TOKENS, makeSummarizer(model, t.summaryModel), 20).catch(() => msgsRef.current);
@@ -271,9 +1043,15 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
271
1043
  checkpoints.current.push({ ref, turnId: mark.turnId, msgLen: mark.msgLen });
272
1044
  const ac = new AbortController(); // steering: ให้ Esc/Ctrl+C หยุด stream กลางทางได้
273
1045
  abortRef.current = ac;
1046
+ resetLiveToolTrail();
1047
+ resetLiveThinking();
1048
+ setStreaming('');
1049
+ setAgentStatus('Starting…');
274
1050
  setBusy(true);
275
1051
  let buf = '';
1052
+ let reasoningBuf = '';
276
1053
  let lastFlush = 0;
1054
+ let lastThinkingFlush = 0;
277
1055
  try {
278
1056
  const { cost, messages, text } = await runAgent({
279
1057
  model,
@@ -285,8 +1063,13 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
285
1063
  permissionMode,
286
1064
  approve: requestApproval,
287
1065
  signal: ac.signal,
1066
+ usageMeta: { sessionId: sessionId.current, source: 'repl' },
288
1067
  onEvent: (e) => {
289
- if (e.type === 'text') {
1068
+ if (e.type === 'status' && typeof e.detail === 'string') {
1069
+ setAgentStatus(e.detail);
1070
+ }
1071
+ else if (e.type === 'text') {
1072
+ setAgentStatus((prev) => (prev.startsWith('Codex') || prev.startsWith('Agent') ? 'Writing…' : prev));
290
1073
  buf += e.text ?? '';
291
1074
  const now = Date.now();
292
1075
  if (now - lastFlush > 80) {
@@ -295,14 +1078,25 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
295
1078
  }
296
1079
  }
297
1080
  else if (e.type === 'tool-call') {
298
- buf += `\n→ ${e.tool}\n`;
299
- setStreaming(buf);
1081
+ recordToolTrailEvent(e);
1082
+ }
1083
+ else if (e.type === 'tool-result' || e.type === 'error') {
1084
+ recordToolTrailEvent(e);
1085
+ }
1086
+ else if (e.type === 'reasoning') {
1087
+ reasoningBuf += e.text ?? '';
1088
+ thinkingRef.current = reasoningBuf;
1089
+ const now = Date.now();
1090
+ if (now - lastThinkingFlush > 120) {
1091
+ setThinking(reasoningBuf);
1092
+ lastThinkingFlush = now;
1093
+ }
300
1094
  }
301
1095
  },
302
1096
  });
303
1097
  msgsRef.current = messages;
304
1098
  lastCost.current = cost.summary();
305
- addTurn('assistant', buf.trim() || text.trim());
1099
+ addTurn('assistant', buf.trim() || text.trim(), { thinking: snapshotThinking(reasoningBuf), toolTrail: snapshotToolTrail() });
306
1100
  // เซฟ session ทุกรอบ → resume ได้ด้วย sanook -c
307
1101
  void saveSession({
308
1102
  id: sessionId.current,
@@ -329,7 +1123,7 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
329
1123
  if (ac.signal.aborted) {
330
1124
  // หยุดเอง — เก็บ partial output ไว้ดู, ทิ้ง turn นี้ออกจาก LLM history (msgsRef ไม่อัปเดต)
331
1125
  if (buf.trim())
332
- addTurn('assistant', buf.trim());
1126
+ addTurn('assistant', buf.trim(), { thinking: snapshotThinking(reasoningBuf), toolTrail: snapshotToolTrail() });
333
1127
  addTurn('system', '⊘ หยุด turn แล้ว (ไฟล์ที่ tool แก้ไปแล้วคืนด้วย /rewind ได้)');
334
1128
  }
335
1129
  else {
@@ -338,6 +1132,9 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
338
1132
  }
339
1133
  finally {
340
1134
  setStreaming('');
1135
+ setAgentStatus('');
1136
+ resetLiveThinking();
1137
+ resetLiveToolTrail();
341
1138
  setBusy(false);
342
1139
  abortRef.current = null;
343
1140
  }
@@ -347,12 +1144,36 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
347
1144
  void submit(next);
348
1145
  }
349
1146
  const costHint = lastCost.current.includes('cost ') ? lastCost.current.split('cost ')[1] : '';
350
- return (_jsxs(Box, { flexDirection: "column", children: [history.length === 0 ? _jsx(Banner, { model: model }) : null, _jsx(Static, { items: history, children: (turn) => _jsx(TurnView, { turn: turn }, turn.id) }), streaming ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { children: streaming }) })) : null, queued.length ? (_jsx(Box, { flexDirection: "column", marginTop: 1, children: queued.map((q, i) => (_jsxs(Text, { dimColor: true, children: ["\u23F3 \u0E04\u0E34\u0E27 ", i + 1, ": ", q.length > 64 ? `${q.slice(0, 64)}…` : q] }, i))) })) : 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 })] })), _jsxs(Text, { dimColor: true, children: [' ', model, " \u00B7 ", permissionMode === 'ask' ? 'ask-mode' : 'auto', " \u00B7 /help \u00B7 @file \u00B7 \u2191 history", costHint ? ` · ${costHint}` : ''] })] }));
1147
+ const contextTokens = estimateTokens(msgsRef.current);
1148
+ const activeQueueIndex = clampQueueActiveIndex(queueActiveIndex, queued.length);
1149
+ const queueWindow = getQueueWindow(queued.length, activeQueueIndex);
1150
+ const toolTrailView = toolTrailLines(toolTrail, columns, toolTrailMode);
1151
+ const thinkingView = thinkingPanelLines(thinking, columns, thinkingMode);
1152
+ const transcriptLimit = transcriptWindowSize(stdout?.rows);
1153
+ const transcriptView = getTranscriptWindow(history.length, transcriptLimit, transcriptScroll);
1154
+ const visibleHistory = history.slice(transcriptView.start, transcriptView.end);
1155
+ 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, agentStatus: agentStatus, toolTrail: toolTrail })] })), _jsx(Text, { dimColor: true, children: footerStatus({
1156
+ branch: gitBranch,
1157
+ backgroundTaskCount: bgTaskCount,
1158
+ busy,
1159
+ columns,
1160
+ contextCompression,
1161
+ contextTokens,
1162
+ costHint,
1163
+ cwd,
1164
+ elapsedSeconds: busyElapsedSeconds,
1165
+ model,
1166
+ mode: permissionMode === 'ask' ? 'ask' : 'auto',
1167
+ queuedCount: queued.length,
1168
+ }) })] }));
351
1169
  }
352
1170
  /** input ที่มี cursor (inverse) + placeholder — minimal; รับ input ได้แม้ busy (ต่อคิว) */
353
- function InputView({ value, cursor, busy }) {
354
- if (busy && !value)
355
- 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)" });
1171
+ function InputView({ value, cursor, busy, agentStatus, toolTrail, }) {
1172
+ if (busy && !value) {
1173
+ const runningTool = toolTrail?.find((item) => item.status === 'running');
1174
+ const detail = agentStatus || (runningTool ? `Tool · ${runningTool.name}` : 'Working…');
1175
+ return (_jsxs(Text, { dimColor: true, 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)"] }));
1176
+ }
356
1177
  if (!busy && !value)
357
1178
  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" });
358
1179
  const before = value.slice(0, cursor);
@@ -360,10 +1181,28 @@ function InputView({ value, cursor, busy }) {
360
1181
  const after = value.slice(cursor + 1);
361
1182
  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] }));
362
1183
  }
363
- function TurnView({ turn }) {
1184
+ function ToolTrailView({ columns, items, mode }) {
1185
+ const lines = toolTrailLines(items, columns, mode);
1186
+ if (!lines.length)
1187
+ return null;
1188
+ return (_jsx(Box, { flexDirection: "column", marginTop: 1, children: lines.map((line, index) => {
1189
+ const isRunning = line.startsWith('>');
1190
+ const isError = line.startsWith('!');
1191
+ const isDone = line.startsWith('+');
1192
+ const isMeta = line.startsWith('view:') || line.startsWith('tools:');
1193
+ return (_jsx(Text, { color: index === 0 ? 'cyan' : isError ? 'red' : isRunning ? 'yellow' : undefined, dimColor: isDone || isMeta, wrap: "truncate-end", children: line }, `${index}-${line}`));
1194
+ }) }));
1195
+ }
1196
+ function ThinkingView({ columns, mode, text }) {
1197
+ const lines = thinkingPanelLines(text, columns, mode);
1198
+ if (!lines.length)
1199
+ return null;
1200
+ 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}`))) }));
1201
+ }
1202
+ function TurnView({ columns, thinkingMode, toolTrailMode, turn, }) {
364
1203
  if (turn.role === 'system')
365
1204
  return _jsx(Text, { dimColor: true, children: turn.text });
366
1205
  if (turn.role === 'user')
367
1206
  return (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "cyan", children: "\u203A " }), _jsx(Text, { color: "cyan", children: turn.text })] }));
368
- return (_jsx(Box, { marginTop: 1, children: _jsx(Text, { children: turn.text }) }));
1207
+ 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] }));
369
1208
  }