sanook-cli 0.5.2 → 0.5.5

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