sanook-cli 0.5.1 → 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 (217) hide show
  1. package/.env.example +161 -3
  2. package/CHANGELOG.md +148 -10
  3. package/README.md +255 -26
  4. package/README.th.md +95 -7
  5. package/dist/approval.js +13 -0
  6. package/dist/bin.js +3552 -155
  7. package/dist/brain-consolidate.js +335 -0
  8. package/dist/brain-context.js +262 -0
  9. package/dist/brain-doctor.js +318 -0
  10. package/dist/brain-eval.js +186 -0
  11. package/dist/brain-final.js +377 -0
  12. package/dist/brain-metrics.js +277 -0
  13. package/dist/brain-new.js +402 -0
  14. package/dist/brain-pack.js +210 -0
  15. package/dist/brain-repair.js +280 -0
  16. package/dist/brain-review.js +382 -0
  17. package/dist/brain.js +15 -1
  18. package/dist/brand.js +1 -1
  19. package/dist/cli-args.js +190 -0
  20. package/dist/cli-option-values.js +16 -0
  21. package/dist/clipboard.js +65 -0
  22. package/dist/commands.js +266 -27
  23. package/dist/compaction.js +96 -11
  24. package/dist/config.js +149 -33
  25. package/dist/context-compression.js +191 -0
  26. package/dist/context-pack.js +145 -0
  27. package/dist/cost.js +49 -15
  28. package/dist/dashboard/api-helpers.js +87 -0
  29. package/dist/dashboard/server.js +179 -0
  30. package/dist/dashboard/static/app.js +277 -0
  31. package/dist/dashboard/static/index.html +39 -0
  32. package/dist/dashboard/static/styles.css +85 -0
  33. package/dist/diff.js +10 -2
  34. package/dist/first-run.js +21 -0
  35. package/dist/gateway/auth.js +49 -9
  36. package/dist/gateway/bluebubbles.js +205 -0
  37. package/dist/gateway/config.js +929 -0
  38. package/dist/gateway/deliver.js +399 -0
  39. package/dist/gateway/discord.js +124 -0
  40. package/dist/gateway/doctor.js +456 -0
  41. package/dist/gateway/email.js +501 -0
  42. package/dist/gateway/googlechat.js +207 -0
  43. package/dist/gateway/homeassistant.js +256 -0
  44. package/dist/gateway/ledger.js +38 -1
  45. package/dist/gateway/line.js +171 -0
  46. package/dist/gateway/lock.js +3 -1
  47. package/dist/gateway/matrix.js +366 -0
  48. package/dist/gateway/mattermost.js +322 -0
  49. package/dist/gateway/ntfy.js +218 -0
  50. package/dist/gateway/schedule.js +31 -4
  51. package/dist/gateway/serve.js +267 -7
  52. package/dist/gateway/server.js +253 -19
  53. package/dist/gateway/service.js +224 -0
  54. package/dist/gateway/session.js +362 -0
  55. package/dist/gateway/signal.js +351 -0
  56. package/dist/gateway/slack.js +124 -0
  57. package/dist/gateway/sms.js +169 -0
  58. package/dist/gateway/targets.js +576 -0
  59. package/dist/gateway/teams.js +106 -0
  60. package/dist/gateway/telegram.js +38 -15
  61. package/dist/gateway/webhooks.js +220 -0
  62. package/dist/gateway/whatsapp.js +230 -0
  63. package/dist/hooks.js +13 -2
  64. package/dist/hotkeys.js +21 -0
  65. package/dist/i18n/en.js +98 -0
  66. package/dist/i18n/index.js +19 -0
  67. package/dist/i18n/th.js +98 -0
  68. package/dist/i18n/types.js +1 -0
  69. package/dist/insights-args.js +55 -0
  70. package/dist/insights.js +86 -0
  71. package/dist/knowledge.js +55 -29
  72. package/dist/loop.js +157 -29
  73. package/dist/lsp/index.js +23 -5
  74. package/dist/mcp-hub.js +33 -0
  75. package/dist/mcp-registry.js +494 -0
  76. package/dist/mcp-risk.js +71 -0
  77. package/dist/mcp-server.js +1 -1
  78. package/dist/mcp.js +120 -10
  79. package/dist/memory-log.js +90 -0
  80. package/dist/memory-store.js +37 -1
  81. package/dist/memory.js +148 -37
  82. package/dist/model-picker.js +58 -0
  83. package/dist/orchestrate.js +51 -19
  84. package/dist/personality.js +58 -0
  85. package/dist/plan-handoff.js +17 -0
  86. package/dist/polyglot.js +162 -0
  87. package/dist/process-runner.js +96 -0
  88. package/dist/project-init.js +91 -0
  89. package/dist/project-registry.js +143 -0
  90. package/dist/project-scaffold.js +124 -0
  91. package/dist/prompt-size.js +155 -0
  92. package/dist/providers/codex-login.js +138 -0
  93. package/dist/providers/codex.js +89 -43
  94. package/dist/providers/keys.js +22 -1
  95. package/dist/providers/models.js +2 -2
  96. package/dist/providers/registry.js +14 -47
  97. package/dist/search/chunk.js +7 -8
  98. package/dist/search/cli.js +83 -0
  99. package/dist/search/embed-store.js +3 -0
  100. package/dist/search/embedding-config.js +22 -0
  101. package/dist/search/engine.js +2 -13
  102. package/dist/search/indexer.js +44 -1
  103. package/dist/search/store.js +23 -1
  104. package/dist/session-distill.js +84 -0
  105. package/dist/session.js +92 -16
  106. package/dist/skill-install.js +53 -13
  107. package/dist/skills.js +33 -0
  108. package/dist/slash-completion.js +155 -0
  109. package/dist/support-dump.js +206 -0
  110. package/dist/tool-catalog.js +59 -0
  111. package/dist/tools/edit.js +45 -15
  112. package/dist/tools/git.js +10 -5
  113. package/dist/tools/homeassistant.js +106 -0
  114. package/dist/tools/index.js +10 -0
  115. package/dist/tools/list.js +19 -6
  116. package/dist/tools/permission.js +992 -12
  117. package/dist/tools/polyglot.js +126 -0
  118. package/dist/tools/read.js +16 -4
  119. package/dist/tools/sandbox.js +38 -13
  120. package/dist/tools/schedule.js +19 -3
  121. package/dist/tools/search.js +226 -15
  122. package/dist/tools/task.js +40 -9
  123. package/dist/tools/timeout.js +23 -3
  124. package/dist/tools/web-fetch-tool.js +33 -0
  125. package/dist/trust.js +11 -1
  126. package/dist/turn-retrieval.js +83 -0
  127. package/dist/ui/app.js +878 -32
  128. package/dist/ui/banner.js +78 -4
  129. package/dist/ui/history.js +37 -5
  130. package/dist/ui/markdown.js +122 -0
  131. package/dist/ui/mentions.js +3 -2
  132. package/dist/ui/overlay.js +496 -0
  133. package/dist/ui/queue.js +23 -0
  134. package/dist/ui/render.js +20 -1
  135. package/dist/ui/session-panel.js +115 -0
  136. package/dist/ui/setup-providers.js +40 -0
  137. package/dist/ui/setup.js +172 -46
  138. package/dist/ui/status.js +142 -0
  139. package/dist/ui/thinking-panel.js +36 -0
  140. package/dist/ui/tool-trail.js +97 -0
  141. package/dist/ui/transcript.js +26 -0
  142. package/dist/ui/useBusyElapsed.js +19 -0
  143. package/dist/ui/useEditor.js +144 -5
  144. package/dist/ui/useGitBranch.js +57 -0
  145. package/dist/update.js +56 -17
  146. package/dist/web-fetch.js +637 -0
  147. package/dist/web-surface.js +190 -0
  148. package/dist/worktree.js +175 -4
  149. package/package.json +5 -5
  150. package/second-brain/AGENTS.md +6 -4
  151. package/second-brain/CLAUDE.md +7 -1
  152. package/second-brain/Evals/_Index.md +10 -2
  153. package/second-brain/Evals/quality-ledger.md +9 -1
  154. package/second-brain/Evals/second-brain-benchmarks.md +62 -0
  155. package/second-brain/GEMINI.md +5 -4
  156. package/second-brain/Home.md +1 -1
  157. package/second-brain/Projects/_Index.md +19 -4
  158. package/second-brain/Projects/sanook-cli/_Index.md +30 -0
  159. package/second-brain/Projects/sanook-cli/context.md +35 -0
  160. package/second-brain/Projects/sanook-cli/current-state.md +32 -0
  161. package/second-brain/Projects/sanook-cli/overview.md +41 -0
  162. package/second-brain/Projects/sanook-cli/repo.md +34 -0
  163. package/second-brain/Projects/sanook-cli/second-brain-feature-roadmap.md +197 -0
  164. package/second-brain/README.md +1 -1
  165. package/second-brain/Research/2026-06-17-ai-second-brain-method-experiment.md +108 -0
  166. package/second-brain/Research/2026-06-18-ai-token-reduction-frameworks.md +55 -0
  167. package/second-brain/Research/2026-06-18-hermes-cli-second-brain-expansion-research.md +160 -0
  168. package/second-brain/Research/2026-06-18-hermes-tui-parity-map.md +129 -0
  169. package/second-brain/Research/2026-06-18-sanook-mcp-ecosystem-and-ux-roadmap.md +181 -0
  170. package/second-brain/Research/2026-06-19-hermes-python-architecture-for-sanook.md +49 -0
  171. package/second-brain/Research/2026-06-19-terminal-ui-brand-research.md +52 -0
  172. package/second-brain/Research/_Index.md +8 -1
  173. package/second-brain/Reviews/2026-06-18-auto-improve-maintenance.md +54 -0
  174. package/second-brain/Reviews/_Index.md +1 -1
  175. package/second-brain/Runbooks/_Index.md +6 -1
  176. package/second-brain/Runbooks/ai-second-brain-operating-sequence.md +108 -0
  177. package/second-brain/SANOOK.md +45 -0
  178. package/second-brain/Sessions/2026-06-17-ai-framework-additional-zones.md +68 -0
  179. package/second-brain/Sessions/2026-06-17-ai-second-brain-sequence-experiment.md +63 -0
  180. package/second-brain/Sessions/2026-06-18-cli-args-release-readiness.md +59 -0
  181. package/second-brain/Sessions/2026-06-18-final-gate-template-final.md +192 -0
  182. package/second-brain/Sessions/2026-06-18-final-gate-template.md +71 -0
  183. package/second-brain/Sessions/2026-06-18-framework-dogfood-permission-and-memory.md +58 -0
  184. package/second-brain/Sessions/2026-06-18-hermes-second-brain-expansion-research.md +52 -0
  185. package/second-brain/Sessions/2026-06-18-mcp-ecosystem-and-sanook-ux-scan.md +81 -0
  186. package/second-brain/Sessions/2026-06-18-sanook-brain-cli-p0-implementation.md +86 -0
  187. package/second-brain/Sessions/2026-06-18-sanook-brain-final-cli-final.md +246 -0
  188. package/second-brain/Sessions/2026-06-18-sanook-brain-final-cli.md +78 -0
  189. package/second-brain/Sessions/2026-06-18-sanook-cli-second-brain-roadmap-correction.md +54 -0
  190. package/second-brain/Sessions/2026-06-18-token-reduction-framework-integration.md +69 -0
  191. package/second-brain/Sessions/_Index.md +15 -1
  192. package/second-brain/Shared/AI-Context-Index.md +22 -0
  193. package/second-brain/Shared/Context-Packs/_Index.md +9 -1
  194. package/second-brain/Shared/Context-Packs/coding-release.md +51 -0
  195. package/second-brain/Shared/Context-Packs/research-to-framework.md +51 -0
  196. package/second-brain/Shared/Context-Packs/second-brain-maintenance.md +41 -0
  197. package/second-brain/Shared/Operating-State/current-state.md +14 -4
  198. package/second-brain/Shared/Scripts/_Index.md +3 -1
  199. package/second-brain/Shared/Scripts/ai-second-brain-method-eval.mjs +198 -0
  200. package/second-brain/Shared/Tech-Standards/_Index.md +6 -1
  201. package/second-brain/Shared/Tech-Standards/mcp-integration-roadmap.md +86 -0
  202. package/second-brain/Shared/Tech-Standards/polyglot-runtime-strategy.md +46 -0
  203. package/second-brain/Shared/Tech-Standards/verification-standard.md +24 -0
  204. package/second-brain/Shared/Tech-Standards/web-search-grounding-policy.md +70 -0
  205. package/second-brain/Shared/User-Memory/_Index.md +4 -1
  206. package/second-brain/Shared/User-Memory/response-examples.md +98 -0
  207. package/second-brain/Shared/User-Memory/user-preferences.md +1 -0
  208. package/second-brain/Templates/_Index.md +9 -0
  209. package/second-brain/Templates/final-lite.md +111 -0
  210. package/second-brain/Templates/final.md +231 -0
  211. package/second-brain/Templates/project-workspace/_Index.md +31 -0
  212. package/second-brain/Templates/project-workspace/context.md +28 -0
  213. package/second-brain/Templates/project-workspace/current-state.md +29 -0
  214. package/second-brain/Templates/project-workspace/overview.md +39 -0
  215. package/second-brain/Templates/project-workspace/repo.md +33 -0
  216. package/second-brain/Vault Structure Map.md +2 -1
  217. package/skills/structured-output-llm/SKILL.md +1 -1
package/dist/ui/app.js CHANGED
@@ -1,25 +1,48 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useState, useRef, useMemo } 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
- import { agentTuning } from '../config.js';
14
+ import { agentTuning, patchGlobalConfig } from '../config.js';
13
15
  import { snapshotWorkTree, restoreWorkTree } from '../checkpoint.js';
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';
14
23
  import { useEditor } from './useEditor.js';
24
+ import { useBusyElapsedSeconds } from './useBusyElapsed.js';
25
+ import { useGitBranch } from './useGitBranch.js';
15
26
  import { loadHistory, appendHistory } from './history.js';
16
27
  import { expandMentions } from './mentions.js';
17
28
  import { BRAND } from '../brand.js';
29
+ import { backgroundTaskRunningCount, listBackgroundTasks } from '../tools/task.js';
18
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';
19
39
  const execFileP = promisify(execFile);
20
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;
21
43
  export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = 'ask', initialHistory, initialNote }) {
22
44
  const { exit } = useApp();
45
+ const { stdout } = useStdout();
23
46
  const [history, setHistory] = useState(() => {
24
47
  const seed = [];
25
48
  if (initialNote)
@@ -29,36 +52,213 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
29
52
  return seed;
30
53
  });
31
54
  const [streaming, setStreaming] = useState('');
55
+ const [thinking, setThinking] = useState('');
56
+ const [toolTrail, setToolTrail] = useState([]);
32
57
  const [busy, setBusy] = useState(false);
33
58
  const [model, setModel] = useState(initialModel);
34
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);
35
68
  const idRef = useRef(0);
36
69
  const lastCost = useRef('');
70
+ const nextToolTrailId = useRef(0);
71
+ const toolTrailRef = useRef([]);
72
+ const toolTrailModeRef = useRef('expanded');
73
+ const thinkingRef = useRef('');
37
74
  const msgsRef = useRef(initialHistory ?? []); // conversation จริงสำหรับ LLM (สะสมข้ามรอบ)
38
75
  const sessionId = useRef(newSessionId());
39
76
  const sessionCreated = useRef(new Date().toISOString());
40
77
  const approvalResolve = useRef(null);
41
78
  const replHistory = useRef(loadHistory()); // prompt เก่า (persist) สำหรับ ↑/↓
42
79
  const checkpoints = useRef([]);
80
+ const lastRun = useRef(null);
43
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
+ });
44
88
  // real-time steering: หยุด turn ที่กำลังรัน (abort) + คิวข้อความที่พิมพ์ระหว่าง busy
45
89
  const abortRef = useRef(null);
46
90
  const queueRef = useRef([]);
47
91
  const [queued, setQueued] = useState([]);
92
+ const [bgTaskCount, setBgTaskCount] = useState(0);
48
93
  const enqueue = (msg) => {
49
94
  queueRef.current.push(msg);
50
95
  setQueued([...queueRef.current]);
96
+ setQueueActiveIndex((index) => clampQueueActiveIndex(index, queueRef.current.length));
51
97
  };
52
98
  const dequeue = () => {
53
99
  const m = queueRef.current.shift();
54
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
+ });
55
108
  return m;
56
109
  };
57
110
  const clearQueue = () => {
58
111
  queueRef.current = [];
59
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;
60
261
  };
61
- const addTurn = (role, text) => setHistory((h) => [...h, { id: idRef.current++, role, text }]);
62
262
  // /diff /undo — git-backed (execFile ไม่ผ่าน shell)
63
263
  async function runGit(args, label) {
64
264
  try {
@@ -74,7 +274,460 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
74
274
  approvalResolve.current = resolve;
75
275
  setApprovalReq({ tool, summary });
76
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
+ };
77
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
+ }
78
731
  // มี approval ค้าง → จับ y/n ก่อน (แม้ agent กำลังรัน/busy)
79
732
  if (approvalReq) {
80
733
  if (input === 'y' || input === 'Y' || key.return) {
@@ -94,16 +747,69 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
94
747
  clearQueue();
95
748
  return;
96
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
+ }
97
762
  // พิมพ์ระหว่าง busy ได้ — Enter = ต่อคิว (รันอัตโนมัติหลัง turn นี้จบ)
98
763
  const a = editor.handleKey(input, key);
99
764
  if (a === 'submit') {
100
765
  const v = editor.value.trim();
766
+ const expanded = editor.expandValue(v).trim();
101
767
  editor.reset();
102
- if (v)
103
- enqueue(v);
768
+ const slash = parseSlashInvocation(v);
769
+ if (slash?.name === 'stop') {
770
+ addTurn('user', v);
771
+ abortRef.current?.abort();
772
+ clearQueue();
773
+ return;
774
+ }
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;
784
+ }
785
+ if (key.downArrow) {
786
+ setCompletionIndex((index) => clampCompletionIndex(index + 1, completions.length));
787
+ return;
104
788
  }
789
+ if (key.tab || key.return) {
790
+ if (applyCompletion())
791
+ return;
792
+ }
793
+ }
794
+ if (key.ctrl && input === 't') {
795
+ noteToolTrailMode(changeToolTrailMode());
105
796
  return;
106
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
+ }
107
813
  const action = editor.handleKey(input, key);
108
814
  if (action === 'submit')
109
815
  void submit(editor.value);
@@ -131,9 +837,26 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
131
837
  : ` · ไฟล์: ${r.reason}`;
132
838
  }
133
839
  msgsRef.current = msgsRef.current.slice(0, cp.msgLen);
134
- setHistory((h) => h.filter((t) => t.id < cp.turnId));
840
+ lastRun.current = null;
841
+ filterHistory((t) => t.id < cp.turnId);
135
842
  addTurn('system', `↩ ย้อนกลับ 1 turn${note}`);
136
843
  }
844
+ async function retryLastTurn() {
845
+ const previous = lastRun.current;
846
+ if (!previous) {
847
+ addTurn('user', '/retry');
848
+ addTurn('system', 'ยังไม่มี turn ให้ retry');
849
+ return;
850
+ }
851
+ msgsRef.current = msgsRef.current.slice(0, previous.msgLen);
852
+ checkpoints.current = checkpoints.current.filter((cp) => cp.turnId < previous.turnId);
853
+ filterHistory((t) => t.id < previous.turnId);
854
+ const mark = { turnId: idRef.current, msgLen: previous.msgLen };
855
+ const preview = previous.userText.length > 120 ? `${previous.userText.slice(0, 117)}...` : previous.userText;
856
+ addTurn('user', '/retry');
857
+ addTurn('system', `retry: ${preview}`);
858
+ await runAssistantTurn(previous.promptText, previous.images, mark, previous.userText);
859
+ }
137
860
  /** บีบ context: 'summarize' (ใช้ model ถูกย่อ) ถ้าตั้งไว้ ไม่งั้น 'truncate' (zero-LLM) */
138
861
  async function compactHistory(targetTokens, label) {
139
862
  const before = estimateTokens(msgsRef.current);
@@ -142,6 +865,8 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
142
865
  return;
143
866
  }
144
867
  const tuning = await agentTuning().catch(() => null);
868
+ if (tuning?.contextCompression)
869
+ setContextCompression(tuning.contextCompression);
145
870
  if (tuning?.compaction === 'summarize') {
146
871
  addTurn('system', '⏳ กำลังย่อ context ด้วย model ถูก…');
147
872
  msgsRef.current = await summarizeCompact(msgsRef.current, targetTokens, makeSummarizer(model, tuning.summaryModel), 20).catch(() => autoCompact(msgsRef.current, targetTokens, 20));
@@ -153,13 +878,14 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
153
878
  }
154
879
  }
155
880
  async function submit(raw) {
156
- const text = raw.trim();
881
+ const displayText = raw.trim();
882
+ const text = editor.expandValue(displayText).trim();
157
883
  editor.reset();
158
- if (!text)
884
+ if (!displayText)
159
885
  return;
160
- appendHistory(text, replHistory.current[replHistory.current.length - 1]);
161
- replHistory.current.push(text);
162
- const slash = parseSlashInvocation(text);
886
+ appendHistory(displayText, replHistory.current[replHistory.current.length - 1]);
887
+ replHistory.current.push(displayText);
888
+ const slash = parseSlashInvocation(displayText);
163
889
  if (slash) {
164
890
  if (slash.name === 'rewind') {
165
891
  await rewind();
@@ -170,36 +896,98 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
170
896
  if (custom) {
171
897
  const expanded = expandCustomCommand(custom, slash.args);
172
898
  const mark = { turnId: idRef.current, msgLen: msgsRef.current.length };
173
- addTurn('user', text);
899
+ addTurn('user', displayText);
174
900
  if (!expanded.trim()) {
175
901
  addTurn('system', `custom command /${slash.name} ว่าง`);
176
902
  return;
177
903
  }
178
- await runAssistantTurn(expanded, [], mark);
904
+ await runAssistantTurn(expanded, [], mark, displayText);
179
905
  return;
180
906
  }
181
907
  }
182
908
  }
183
- const cmd = parseCommand(text, { model, costSummary: lastCost.current });
909
+ const cmd = parseCommand(displayText, { model, costSummary: lastCost.current });
184
910
  if (cmd.handled) {
185
- addTurn('user', text);
911
+ addTurn('user', displayText);
186
912
  if (cmd.action === 'quit')
187
913
  return exit();
188
914
  if (cmd.action === 'clear') {
189
915
  msgsRef.current = [];
190
916
  checkpoints.current = [];
191
- return setHistory([]);
917
+ lastRun.current = null;
918
+ setStreaming('');
919
+ resetLiveToolTrail();
920
+ replaceHistory([]);
921
+ return;
192
922
  }
193
923
  if (cmd.action === 'compact') {
194
924
  void compactHistory(40_000, 'บีบ context');
195
925
  return;
196
926
  }
927
+ if (cmd.action === 'copyLast') {
928
+ void copyLatestAssistant();
929
+ return;
930
+ }
197
931
  if (cmd.action === 'diff')
198
932
  return void runGit(['diff', '--stat'], 'diff');
933
+ if (cmd.action === 'retry')
934
+ return void retryLastTurn();
935
+ if (cmd.action === 'personality') {
936
+ void patchGlobalConfig({ personality: cmd.personalityChange || undefined })
937
+ .then(() => addTurn('system', cmd.message ?? 'ตั้ง personality แล้ว'))
938
+ .catch((e) => addTurn('system', `personality: ${e.message}`));
939
+ return;
940
+ }
941
+ if (cmd.action === 'insights') {
942
+ void renderInsights({ days: cmd.insightsDays, cwd: cmd.insightsAll ? null : undefined })
943
+ .then((msg) => addTurn('system', msg))
944
+ .catch((e) => addTurn('system', `insights: ${e.message}`));
945
+ return;
946
+ }
199
947
  if (cmd.action === 'undo') {
200
948
  void runGit(['stash', 'push', '-u', '-m', BRAND.undoStashMessage], 'undo').then(() => addTurn('system', 'กู้คืน: git stash pop'));
201
949
  return;
202
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
+ }
203
991
  if (cmd.modelChange)
204
992
  setModel(cmd.modelChange);
205
993
  if (cmd.message)
@@ -208,17 +996,20 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
208
996
  }
209
997
  // prompt ปกติ → expand @mentions (inline ไฟล์ text + เก็บ path รูป)
210
998
  const mark = { turnId: idRef.current, msgLen: msgsRef.current.length };
211
- addTurn('user', text);
999
+ addTurn('user', displayText);
212
1000
  const { text: expanded, images, errors } = await expandMentions(text);
213
1001
  if (errors.length)
214
1002
  addTurn('system', `@mention: ${errors.join(' · ')}`);
215
- await runAssistantTurn(expanded, images, mark);
1003
+ await runAssistantTurn(expanded, images, mark, displayText);
216
1004
  }
217
- async function runAssistantTurn(promptText, images, mark) {
1005
+ async function runAssistantTurn(promptText, images, mark, userText = promptText) {
1006
+ lastRun.current = { ...mark, userText, promptText, images };
218
1007
  // proactive summarize-compaction สำหรับ session ยาวมาก (เฉพาะ mode summarize) — เริ่ม turn ให้ context lean
219
1008
  // (mode truncate: ปล่อยให้ loop.ts ตัดต่อ-step เอา; ไม่บีบที่นี่ กัน latency)
220
1009
  if (estimateTokens(msgsRef.current) > PRE_TURN_COMPACT_TOKENS) {
221
1010
  const t = await agentTuning().catch(() => null);
1011
+ if (t?.contextCompression)
1012
+ setContextCompression(t.contextCompression);
222
1013
  if (t?.compaction === 'summarize') {
223
1014
  addTurn('system', '⏳ context ยาว — ย่ออัตโนมัติก่อนรอบนี้…');
224
1015
  msgsRef.current = await summarizeCompact(msgsRef.current, PRE_TURN_COMPACT_TOKENS, makeSummarizer(model, t.summaryModel), 20).catch(() => msgsRef.current);
@@ -229,9 +1020,14 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
229
1020
  checkpoints.current.push({ ref, turnId: mark.turnId, msgLen: mark.msgLen });
230
1021
  const ac = new AbortController(); // steering: ให้ Esc/Ctrl+C หยุด stream กลางทางได้
231
1022
  abortRef.current = ac;
1023
+ resetLiveToolTrail();
1024
+ resetLiveThinking();
1025
+ setStreaming('');
232
1026
  setBusy(true);
233
1027
  let buf = '';
1028
+ let reasoningBuf = '';
234
1029
  let lastFlush = 0;
1030
+ let lastThinkingFlush = 0;
235
1031
  try {
236
1032
  const { cost, messages, text } = await runAgent({
237
1033
  model,
@@ -253,14 +1049,25 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
253
1049
  }
254
1050
  }
255
1051
  else if (e.type === 'tool-call') {
256
- buf += `\n→ ${e.tool}\n`;
257
- 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
+ }
258
1065
  }
259
1066
  },
260
1067
  });
261
1068
  msgsRef.current = messages;
262
1069
  lastCost.current = cost.summary();
263
- addTurn('assistant', buf.trim() || text.trim());
1070
+ addTurn('assistant', buf.trim() || text.trim(), { thinking: snapshotThinking(reasoningBuf), toolTrail: snapshotToolTrail() });
264
1071
  // เซฟ session ทุกรอบ → resume ได้ด้วย sanook -c
265
1072
  void saveSession({
266
1073
  id: sessionId.current,
@@ -287,7 +1094,7 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
287
1094
  if (ac.signal.aborted) {
288
1095
  // หยุดเอง — เก็บ partial output ไว้ดู, ทิ้ง turn นี้ออกจาก LLM history (msgsRef ไม่อัปเดต)
289
1096
  if (buf.trim())
290
- addTurn('assistant', buf.trim());
1097
+ addTurn('assistant', buf.trim(), { thinking: snapshotThinking(reasoningBuf), toolTrail: snapshotToolTrail() });
291
1098
  addTurn('system', '⊘ หยุด turn แล้ว (ไฟล์ที่ tool แก้ไปแล้วคืนด้วย /rewind ได้)');
292
1099
  }
293
1100
  else {
@@ -296,6 +1103,8 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
296
1103
  }
297
1104
  finally {
298
1105
  setStreaming('');
1106
+ resetLiveThinking();
1107
+ resetLiveToolTrail();
299
1108
  setBusy(false);
300
1109
  abortRef.current = null;
301
1110
  }
@@ -304,10 +1113,29 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
304
1113
  if (next)
305
1114
  void submit(next);
306
1115
  }
307
- // banner ผูกกับ live `model` (ไม่ใช่ initialModel) → /model เปลี่ยนแล้ว banner อัปเดตตาม ไม่ค้าง model เก่า
308
- const banner = useMemo(() => _jsx(Banner, { model: model }), [model]);
309
1116
  const costHint = lastCost.current.includes('cost ') ? lastCost.current.split('cost ')[1] : '';
310
- return (_jsxs(Box, { flexDirection: "column", children: [banner, _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
+ }) })] }));
311
1139
  }
312
1140
  /** input ที่มี cursor (inverse) + placeholder — minimal; รับ input ได้แม้ busy (ต่อคิว) */
313
1141
  function InputView({ value, cursor, busy }) {
@@ -320,10 +1148,28 @@ function InputView({ value, cursor, busy }) {
320
1148
  const after = value.slice(cursor + 1);
321
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] }));
322
1150
  }
323
- 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, }) {
324
1170
  if (turn.role === 'system')
325
1171
  return _jsx(Text, { dimColor: true, children: turn.text });
326
1172
  if (turn.role === 'user')
327
1173
  return (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "cyan", children: "\u203A " }), _jsx(Text, { color: "cyan", children: turn.text })] }));
328
- 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] }));
329
1175
  }