sanook-cli 0.4.0 → 0.5.0

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 (235) hide show
  1. package/.env.example +19 -0
  2. package/CHANGELOG.md +144 -0
  3. package/README.md +153 -20
  4. package/README.th.md +136 -0
  5. package/dist/agentContext.js +4 -0
  6. package/dist/approval.js +6 -0
  7. package/dist/bin.js +394 -51
  8. package/dist/brain.js +92 -59
  9. package/dist/brand.js +47 -0
  10. package/dist/checkpoint.js +37 -0
  11. package/dist/commands.js +86 -6
  12. package/dist/compaction.js +76 -5
  13. package/dist/config.js +100 -12
  14. package/dist/cost.js +60 -3
  15. package/dist/doctor.js +92 -0
  16. package/dist/gateway/auth.js +2 -2
  17. package/dist/gateway/ledger.js +2 -2
  18. package/dist/gateway/scheduler.js +1 -0
  19. package/dist/gateway/serve.js +6 -4
  20. package/dist/gateway/server.js +10 -2
  21. package/dist/git.js +11 -2
  22. package/dist/hooks.js +43 -17
  23. package/dist/knowledge.js +48 -49
  24. package/dist/loop.js +182 -66
  25. package/dist/lsp/client.js +173 -0
  26. package/dist/lsp/framing.js +56 -0
  27. package/dist/lsp/index.js +138 -0
  28. package/dist/lsp/servers.js +82 -0
  29. package/dist/mcp-server.js +244 -0
  30. package/dist/mcp.js +184 -29
  31. package/dist/memory-store.js +559 -0
  32. package/dist/memory.js +143 -29
  33. package/dist/orchestrate.js +150 -0
  34. package/dist/providers/codex.js +2 -2
  35. package/dist/providers/keys.js +3 -2
  36. package/dist/providers/registry.js +133 -1
  37. package/dist/repomap.js +93 -0
  38. package/dist/search/chunk.js +158 -0
  39. package/dist/search/embed-store.js +187 -0
  40. package/dist/search/engine.js +203 -0
  41. package/dist/search/fuse.js +35 -0
  42. package/dist/search/index-core.js +187 -0
  43. package/dist/search/indexer.js +241 -0
  44. package/dist/search/store.js +77 -0
  45. package/dist/session.js +42 -8
  46. package/dist/skill-install.js +10 -10
  47. package/dist/skills.js +12 -9
  48. package/dist/summarize.js +31 -0
  49. package/dist/tools/bash.js +21 -2
  50. package/dist/tools/diagnostics.js +41 -0
  51. package/dist/tools/edit.js +29 -7
  52. package/dist/tools/index.js +8 -1
  53. package/dist/tools/list.js +7 -2
  54. package/dist/tools/permission.js +90 -9
  55. package/dist/tools/read.js +23 -4
  56. package/dist/tools/remember.js +1 -1
  57. package/dist/tools/sandbox.js +61 -0
  58. package/dist/tools/search.js +105 -4
  59. package/dist/tools/task.js +195 -29
  60. package/dist/tools/timeout.js +35 -0
  61. package/dist/tools/util.js +10 -0
  62. package/dist/tools/write.js +6 -4
  63. package/dist/trust.js +89 -0
  64. package/dist/ui/app.js +218 -27
  65. package/dist/ui/banner.js +4 -9
  66. package/dist/ui/history.js +30 -0
  67. package/dist/ui/mentions.js +44 -0
  68. package/dist/ui/setup.js +6 -5
  69. package/dist/ui/useEditor.js +83 -0
  70. package/dist/update.js +114 -0
  71. package/dist/worktree.js +173 -0
  72. package/package.json +11 -5
  73. package/scripts/postinstall.mjs +33 -0
  74. package/second-brain/.agents/_Index.md +30 -0
  75. package/second-brain/.agents/skills/_Index.md +30 -0
  76. package/second-brain/.agents/workflows/_Index.md +30 -0
  77. package/second-brain/AGENTS.md +4 -4
  78. package/second-brain/Acceptance/_Index.md +30 -0
  79. package/second-brain/Acceptance/golden-case-template.md +39 -0
  80. package/second-brain/Areas/_Index.md +30 -0
  81. package/second-brain/Bugs/System-OS/_Index.md +30 -0
  82. package/second-brain/Bugs/_Index.md +30 -0
  83. package/second-brain/CLAUDE.md +4 -1
  84. package/second-brain/Checklists/_Index.md +30 -0
  85. package/second-brain/Checklists/preflight-postflight-template.md +29 -0
  86. package/second-brain/Distillations/_Index.md +30 -0
  87. package/second-brain/Entities/_Index.md +30 -0
  88. package/second-brain/Entities/entity-template.md +33 -0
  89. package/second-brain/Evals/_Index.md +30 -0
  90. package/second-brain/Evals/correction-pairs.md +24 -0
  91. package/second-brain/Evals/failure-taxonomy.md +24 -0
  92. package/second-brain/Evals/golden-set.md +25 -0
  93. package/second-brain/Evals/quality-ledger.md +23 -0
  94. package/second-brain/Evals/self-eval-rubric.md +23 -0
  95. package/second-brain/GEMINI.md +4 -4
  96. package/second-brain/Goals/_Index.md +30 -0
  97. package/second-brain/Handoffs/_Index.md +30 -0
  98. package/second-brain/Home.md +7 -0
  99. package/second-brain/Intake/Raw Sources/_Index.md +30 -0
  100. package/second-brain/Intake/_Index.md +30 -0
  101. package/second-brain/Intake/_Quarantine/_Index.md +30 -0
  102. package/second-brain/Learning/_Index.md +30 -0
  103. package/second-brain/Playbooks/_Index.md +30 -0
  104. package/second-brain/Playbooks/playbook-template.md +23 -0
  105. package/second-brain/Projects/_Index.md +30 -0
  106. package/second-brain/Prompts/_Index.md +30 -0
  107. package/second-brain/README.md +2 -1
  108. package/second-brain/Research/_Index.md +30 -0
  109. package/second-brain/Retrospectives/_Index.md +30 -0
  110. package/second-brain/Reviews/_Index.md +30 -0
  111. package/second-brain/Runbooks/_Index.md +30 -0
  112. package/second-brain/Runbooks/eval-loop.md +24 -0
  113. package/second-brain/Sessions/_Index.md +30 -0
  114. package/second-brain/Shared/AI-Context-Index.md +20 -0
  115. package/second-brain/Shared/AI-Threads/_Index.md +30 -0
  116. package/second-brain/Shared/Archive/_Index.md +30 -0
  117. package/second-brain/Shared/Assets/_Index.md +30 -0
  118. package/second-brain/Shared/Context-Packs/_Index.md +30 -0
  119. package/second-brain/Shared/Context7-Docs/_Index.md +30 -0
  120. package/second-brain/Shared/Coordination/NOW.md +28 -0
  121. package/second-brain/Shared/Coordination/_Index.md +30 -0
  122. package/second-brain/Shared/Coordination/agent-registry.md +24 -0
  123. package/second-brain/Shared/Coordination/task-board/_Index.md +30 -0
  124. package/second-brain/Shared/Coordination/task-board/task-template.md +43 -0
  125. package/second-brain/Shared/Coordination/task-board.md +32 -0
  126. package/second-brain/Shared/Core-Facts/_Index.md +30 -0
  127. package/second-brain/Shared/Decision-Memory/_Index.md +30 -0
  128. package/second-brain/Shared/Glossary/_Index.md +30 -0
  129. package/second-brain/Shared/Memory-Inbox/_Index.md +30 -0
  130. package/second-brain/Shared/Operating-State/_Index.md +30 -0
  131. package/second-brain/Shared/Prompting/_Index.md +30 -0
  132. package/second-brain/Shared/Provenance/_Index.md +30 -0
  133. package/second-brain/Shared/Rules/_Index.md +30 -0
  134. package/second-brain/Shared/Rules/contextual-note-rule.md +30 -0
  135. package/second-brain/Shared/Rules/frontmatter-standard.md +10 -0
  136. package/second-brain/Shared/Rules/memory-write-protocol.md +28 -0
  137. package/second-brain/Shared/Rules/procedural-runbook-header.md +40 -0
  138. package/second-brain/Shared/Rules/review-and-staleness-policy.md +22 -0
  139. package/second-brain/Shared/Rules/rules-formatting.md +34 -0
  140. package/second-brain/Shared/Scripts/_Index.md +30 -0
  141. package/second-brain/Shared/Scripts-Archive/_Index.md +30 -0
  142. package/second-brain/Shared/Tech-Standards/_Index.md +30 -0
  143. package/second-brain/Shared/Tech-Standards/verification-standard.md +40 -0
  144. package/second-brain/Shared/User-Memory/_Index.md +30 -0
  145. package/second-brain/Shared/User-Persona/_Index.md +30 -0
  146. package/second-brain/Shared/User-Persona/owner-profile.md +25 -0
  147. package/second-brain/Shared/Working-Memory/_Index.md +30 -0
  148. package/second-brain/Shared/_Index.md +30 -0
  149. package/second-brain/Shared/mcp-servers/_Index.md +30 -0
  150. package/second-brain/Skills/_Index.md +30 -0
  151. package/second-brain/Templates/_Index.md +30 -0
  152. package/second-brain/Templates/bug.md +2 -0
  153. package/second-brain/Templates/handoff.md +2 -0
  154. package/second-brain/Templates/session.md +2 -0
  155. package/second-brain/Tools/_Index.md +30 -0
  156. package/second-brain/Traces/_Index.md +30 -0
  157. package/second-brain/Vault Structure Map.md +33 -1
  158. package/second-brain/copilot/_Index.md +30 -0
  159. package/skills/audit-license-compliance/SKILL.md +117 -0
  160. package/skills/author-codemod/SKILL.md +110 -0
  161. package/skills/build-audit-logging/SKILL.md +112 -0
  162. package/skills/build-cdc-streaming-pipeline/SKILL.md +123 -0
  163. package/skills/build-cli-tool/SKILL.md +108 -0
  164. package/skills/build-data-table/SKILL.md +141 -0
  165. package/skills/build-native-mobile-ui/SKILL.md +154 -0
  166. package/skills/build-offline-first-sync/SKILL.md +118 -0
  167. package/skills/build-realtime-channel/SKILL.md +122 -0
  168. package/skills/build-vector-search/SKILL.md +131 -0
  169. package/skills/compose-local-dev-stack/SKILL.md +149 -0
  170. package/skills/configure-bundler-build/SKILL.md +166 -0
  171. package/skills/configure-dns-tls/SKILL.md +142 -0
  172. package/skills/configure-reverse-proxy-lb/SKILL.md +129 -0
  173. package/skills/configure-security-headers-csp/SKILL.md +122 -0
  174. package/skills/contract-testing/SKILL.md +140 -0
  175. package/skills/datetime-timezone-correctness/SKILL.md +125 -0
  176. package/skills/debug-ci-pipeline-failure/SKILL.md +134 -0
  177. package/skills/debug-flaky-tests/SKILL.md +128 -0
  178. package/skills/defend-llm-prompt-injection/SKILL.md +110 -0
  179. package/skills/deliver-webhooks/SKILL.md +116 -0
  180. package/skills/design-api-pagination/SKILL.md +144 -0
  181. package/skills/design-authorization-model/SKILL.md +119 -0
  182. package/skills/design-backup-dr-recovery/SKILL.md +113 -0
  183. package/skills/design-event-sourcing-cqrs/SKILL.md +143 -0
  184. package/skills/design-multi-tenancy/SKILL.md +100 -0
  185. package/skills/design-protobuf-grpc-service/SKILL.md +146 -0
  186. package/skills/design-relational-schema/SKILL.md +129 -0
  187. package/skills/design-search-index-infra/SKILL.md +151 -0
  188. package/skills/design-state-machine/SKILL.md +108 -0
  189. package/skills/design-token-system/SKILL.md +109 -0
  190. package/skills/distributed-locks-leases/SKILL.md +120 -0
  191. package/skills/encrypt-sensitive-data/SKILL.md +148 -0
  192. package/skills/feature-flags-rollout/SKILL.md +130 -0
  193. package/skills/file-upload-object-storage/SKILL.md +107 -0
  194. package/skills/fuzz-dynamic-security-test/SKILL.md +111 -0
  195. package/skills/harden-llm-app-reliability/SKILL.md +126 -0
  196. package/skills/i18n-localization-setup/SKILL.md +113 -0
  197. package/skills/idempotency-keys/SKILL.md +107 -0
  198. package/skills/implement-push-notifications/SKILL.md +142 -0
  199. package/skills/ingest-webhook-secure/SKILL.md +120 -0
  200. package/skills/integrate-oauth-oidc/SKILL.md +126 -0
  201. package/skills/load-stress-test/SKILL.md +129 -0
  202. package/skills/map-privacy-data-gdpr/SKILL.md +146 -0
  203. package/skills/model-nosql-data/SKILL.md +118 -0
  204. package/skills/money-decimal-arithmetic/SKILL.md +123 -0
  205. package/skills/monitor-ml-drift/SKILL.md +109 -0
  206. package/skills/numeric-precision-units/SKILL.md +144 -0
  207. package/skills/optimize-llm-cost-latency/SKILL.md +103 -0
  208. package/skills/optimize-react-rerenders/SKILL.md +124 -0
  209. package/skills/orchestrate-agent-workflow/SKILL.md +100 -0
  210. package/skills/payments-billing-integration/SKILL.md +114 -0
  211. package/skills/pin-toolchain-versions/SKILL.md +116 -0
  212. package/skills/plan-strangler-migration/SKILL.md +95 -0
  213. package/skills/property-based-testing/SKILL.md +108 -0
  214. package/skills/publish-package-registry/SKILL.md +130 -0
  215. package/skills/recover-git-state/SKILL.md +119 -0
  216. package/skills/remediate-web-vulnerabilities/SKILL.md +125 -0
  217. package/skills/resilience-timeouts-retries/SKILL.md +104 -0
  218. package/skills/resolve-merge-rebase-conflict/SKILL.md +97 -0
  219. package/skills/rewrite-git-history/SKILL.md +109 -0
  220. package/skills/scaffold-cross-platform-app/SKILL.md +137 -0
  221. package/skills/schema-evolution-compatibility/SKILL.md +121 -0
  222. package/skills/send-transactional-email/SKILL.md +126 -0
  223. package/skills/serve-deploy-ml-model/SKILL.md +107 -0
  224. package/skills/setup-cdn-edge-waf/SKILL.md +107 -0
  225. package/skills/setup-devcontainer-env/SKILL.md +131 -0
  226. package/skills/setup-lint-format-precommit/SKILL.md +140 -0
  227. package/skills/setup-monorepo-tooling/SKILL.md +125 -0
  228. package/skills/ship-mobile-app-store-release/SKILL.md +137 -0
  229. package/skills/structured-output-llm/SKILL.md +86 -0
  230. package/skills/supply-chain-sbom-provenance/SKILL.md +120 -0
  231. package/skills/test-data-factories/SKILL.md +158 -0
  232. package/skills/threat-model-stride/SKILL.md +123 -0
  233. package/skills/train-evaluate-ml-model/SKILL.md +109 -0
  234. package/skills/unicode-text-correctness/SKILL.md +109 -0
  235. package/skills/visual-regression-testing/SKILL.md +120 -0
package/dist/ui/app.js CHANGED
@@ -1,16 +1,28 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useState, useRef, useMemo } from 'react';
3
+ import { execFile } from 'node:child_process';
4
+ import { promisify } from 'node:util';
3
5
  import { Box, Text, Static, useApp, useInput } from 'ink';
4
- import { parseCommand } from '../commands.js';
6
+ import { BUILTIN_COMMANDS, expandCustomCommand, loadCustomCommands, parseCommand, parseSlashInvocation, } from '../commands.js';
5
7
  import { runAgent } from '../loop.js';
6
8
  import { saveSession, newSessionId } from '../session.js';
9
+ import { getBrainPath, appendBrainWorklog } from '../memory.js';
10
+ import { autoCompact, estimateTokens, summarizeCompact } from '../compaction.js';
11
+ import { makeSummarizer } from '../summarize.js';
12
+ import { agentTuning } from '../config.js';
13
+ import { snapshotWorkTree, restoreWorkTree } from '../checkpoint.js';
14
+ import { useEditor } from './useEditor.js';
15
+ import { loadHistory, appendHistory } from './history.js';
16
+ import { expandMentions } from './mentions.js';
17
+ import { BRAND } from '../brand.js';
7
18
  import { Banner } from './banner.js';
8
- export function App({ initialModel, budgetUsd, permissionMode = 'auto', initialHistory }) {
19
+ const execFileP = promisify(execFile);
20
+ const PRE_TURN_COMPACT_TOKENS = 100_000; // session ยาวมากเท่านั้นถึง summarize ก่อน turn (mode summarize)
21
+ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = 'ask', initialHistory }) {
9
22
  const { exit } = useApp();
10
23
  const [history, setHistory] = useState(initialHistory?.length
11
24
  ? [{ id: -1, role: 'system', text: `↻ ต่อจาก session ก่อน (${initialHistory.length} ข้อความ)` }]
12
25
  : []);
13
- const [input, setInput] = useState('');
14
26
  const [streaming, setStreaming] = useState('');
15
27
  const [busy, setBusy] = useState(false);
16
28
  const [model, setModel] = useState(initialModel);
@@ -21,45 +33,148 @@ export function App({ initialModel, budgetUsd, permissionMode = 'auto', initialH
21
33
  const sessionId = useRef(newSessionId());
22
34
  const sessionCreated = useRef(new Date().toISOString());
23
35
  const approvalResolve = useRef(null);
36
+ const replHistory = useRef(loadHistory()); // prompt เก่า (persist) สำหรับ ↑/↓
37
+ const checkpoints = useRef([]);
38
+ const editor = useEditor(replHistory.current);
39
+ // real-time steering: หยุด turn ที่กำลังรัน (abort) + คิวข้อความที่พิมพ์ระหว่าง busy
40
+ const abortRef = useRef(null);
41
+ const queueRef = useRef([]);
42
+ const [queued, setQueued] = useState([]);
43
+ const enqueue = (msg) => {
44
+ queueRef.current.push(msg);
45
+ setQueued([...queueRef.current]);
46
+ };
47
+ const dequeue = () => {
48
+ const m = queueRef.current.shift();
49
+ setQueued([...queueRef.current]);
50
+ return m;
51
+ };
52
+ const clearQueue = () => {
53
+ queueRef.current = [];
54
+ setQueued([]);
55
+ };
24
56
  const addTurn = (role, text) => setHistory((h) => [...h, { id: idRef.current++, role, text }]);
57
+ // /diff /undo — git-backed (execFile ไม่ผ่าน shell)
58
+ async function runGit(args, label) {
59
+ try {
60
+ const { stdout, stderr } = await execFileP('git', args, { cwd: process.cwd(), maxBuffer: 1_000_000 });
61
+ addTurn('system', (stdout || stderr).trim() || `(${label}: ไม่มีการเปลี่ยนแปลง)`);
62
+ }
63
+ catch (e) {
64
+ addTurn('system', `git ${label}: ${e.message.split('\n')[0]}`);
65
+ }
66
+ }
25
67
  // ask-mode: tool ขออนุมัติ → คืน Promise ที่ resolve เมื่อ user กด y/n
26
68
  const requestApproval = (tool, summary) => new Promise((resolve) => {
27
69
  approvalResolve.current = resolve;
28
70
  setApprovalReq({ tool, summary });
29
71
  });
30
- useInput((char, key) => {
72
+ useInput((input, key) => {
31
73
  // มี approval ค้าง → จับ y/n ก่อน (แม้ agent กำลังรัน/busy)
32
74
  if (approvalReq) {
33
- if (char === 'y' || char === 'Y' || key.return) {
75
+ if (input === 'y' || input === 'Y' || key.return) {
34
76
  approvalResolve.current?.(true);
35
77
  setApprovalReq(null);
36
78
  }
37
- else if (char === 'n' || char === 'N' || key.escape) {
79
+ else if (input === 'n' || input === 'N' || key.escape) {
38
80
  approvalResolve.current?.(false);
39
81
  setApprovalReq(null);
40
82
  }
41
83
  return;
42
84
  }
43
- if (busy)
85
+ if (busy) {
86
+ // steering ระหว่าง turn: Esc / Ctrl+C = หยุด turn นี้ (ไม่ออกจากแอป) + ล้างคิว
87
+ if (key.escape || (key.ctrl && input === 'c')) {
88
+ abortRef.current?.abort();
89
+ clearQueue();
90
+ return;
91
+ }
92
+ // พิมพ์ระหว่าง busy ได้ — Enter = ต่อคิว (รันอัตโนมัติหลัง turn นี้จบ)
93
+ const a = editor.handleKey(input, key);
94
+ if (a === 'submit') {
95
+ const v = editor.value.trim();
96
+ editor.reset();
97
+ if (v)
98
+ enqueue(v);
99
+ }
100
+ return;
101
+ }
102
+ const action = editor.handleKey(input, key);
103
+ if (action === 'submit')
104
+ void submit(editor.value);
105
+ else if (action === 'interrupt') {
106
+ if (editor.value)
107
+ editor.reset(); // Ctrl+C ครั้งแรก = ล้างบรรทัด, ว่างแล้ว = ออก
108
+ else
109
+ exit();
110
+ }
111
+ });
112
+ /** ย้อน 1 turn — คืนไฟล์ (git, recoverable) + ตัดบทสนทนากลับ */
113
+ async function rewind() {
114
+ const cp = checkpoints.current.pop();
115
+ if (!cp) {
116
+ addTurn('system', 'ไม่มี checkpoint ให้ย้อน');
44
117
  return;
45
- if (key.return) {
46
- void submit();
47
118
  }
48
- else if (key.backspace || key.delete) {
49
- setInput((s) => s.slice(0, -1));
119
+ let note = '';
120
+ if (cp.ref) {
121
+ const r = await restoreWorkTree(cp.ref);
122
+ note = r.ok
123
+ ? r.recovery
124
+ ? ` · ไฟล์คืนแล้ว (กู้สถานะก่อนหน้า: ${r.recovery})`
125
+ : ' · ไฟล์คืนแล้ว'
126
+ : ` · ไฟล์: ${r.reason}`;
50
127
  }
51
- else if (key.ctrl && char === 'c') {
52
- exit();
128
+ msgsRef.current = msgsRef.current.slice(0, cp.msgLen);
129
+ setHistory((h) => h.filter((t) => t.id < cp.turnId));
130
+ addTurn('system', `↩ ย้อนกลับ 1 turn${note}`);
131
+ }
132
+ /** บีบ context: 'summarize' (ใช้ model ถูกย่อ) ถ้าตั้งไว้ ไม่งั้น 'truncate' (zero-LLM) */
133
+ async function compactHistory(targetTokens, label) {
134
+ const before = estimateTokens(msgsRef.current);
135
+ if (before <= targetTokens) {
136
+ addTurn('system', `context ~${before} tokens — ยังไม่ต้องบีบ`);
137
+ return;
53
138
  }
54
- else if (char && !key.ctrl && !key.meta) {
55
- setInput((s) => s + char);
139
+ const tuning = await agentTuning().catch(() => null);
140
+ if (tuning?.compaction === 'summarize') {
141
+ addTurn('system', '⏳ กำลังย่อ context ด้วย model ถูก…');
142
+ msgsRef.current = await summarizeCompact(msgsRef.current, targetTokens, makeSummarizer(model, tuning.summaryModel), 20).catch(() => autoCompact(msgsRef.current, targetTokens, 20));
143
+ addTurn('system', `ย่อ context แล้ว (summarize): ~${before} → ~${estimateTokens(msgsRef.current)} tokens`);
56
144
  }
57
- });
58
- async function submit() {
59
- const text = input.trim();
145
+ else {
146
+ msgsRef.current = autoCompact(msgsRef.current, targetTokens, 20);
147
+ addTurn('system', `บีบ context แล้ว: ~${before} → ~${estimateTokens(msgsRef.current)} tokens`);
148
+ }
149
+ }
150
+ async function submit(raw) {
151
+ const text = raw.trim();
152
+ editor.reset();
60
153
  if (!text)
61
154
  return;
62
- setInput('');
155
+ appendHistory(text, replHistory.current[replHistory.current.length - 1]);
156
+ replHistory.current.push(text);
157
+ const slash = parseSlashInvocation(text);
158
+ if (slash) {
159
+ if (slash.name === 'rewind') {
160
+ await rewind();
161
+ return;
162
+ }
163
+ if (!BUILTIN_COMMANDS.has(slash.name)) {
164
+ const custom = (await loadCustomCommands()).get(slash.name);
165
+ if (custom) {
166
+ const expanded = expandCustomCommand(custom, slash.args);
167
+ const mark = { turnId: idRef.current, msgLen: msgsRef.current.length };
168
+ addTurn('user', text);
169
+ if (!expanded.trim()) {
170
+ addTurn('system', `custom command /${slash.name} ว่าง`);
171
+ return;
172
+ }
173
+ await runAssistantTurn(expanded, [], mark);
174
+ return;
175
+ }
176
+ }
177
+ }
63
178
  const cmd = parseCommand(text, { model, costSummary: lastCost.current });
64
179
  if (cmd.handled) {
65
180
  addTurn('user', text);
@@ -67,26 +182,62 @@ export function App({ initialModel, budgetUsd, permissionMode = 'auto', initialH
67
182
  return exit();
68
183
  if (cmd.action === 'clear') {
69
184
  msgsRef.current = [];
185
+ checkpoints.current = [];
70
186
  return setHistory([]);
71
187
  }
188
+ if (cmd.action === 'compact') {
189
+ void compactHistory(40_000, 'บีบ context');
190
+ return;
191
+ }
192
+ if (cmd.action === 'diff')
193
+ return void runGit(['diff', '--stat'], 'diff');
194
+ if (cmd.action === 'undo') {
195
+ void runGit(['stash', 'push', '-u', '-m', BRAND.undoStashMessage], 'undo').then(() => addTurn('system', 'กู้คืน: git stash pop'));
196
+ return;
197
+ }
72
198
  if (cmd.modelChange)
73
199
  setModel(cmd.modelChange);
74
200
  if (cmd.message)
75
201
  addTurn('system', cmd.message);
76
202
  return;
77
203
  }
204
+ // prompt ปกติ → expand @mentions (inline ไฟล์ text + เก็บ path รูป)
205
+ const mark = { turnId: idRef.current, msgLen: msgsRef.current.length };
78
206
  addTurn('user', text);
207
+ const { text: expanded, images, errors } = await expandMentions(text);
208
+ if (errors.length)
209
+ addTurn('system', `@mention: ${errors.join(' · ')}`);
210
+ await runAssistantTurn(expanded, images, mark);
211
+ }
212
+ async function runAssistantTurn(promptText, images, mark) {
213
+ // proactive summarize-compaction สำหรับ session ยาวมาก (เฉพาะ mode summarize) — เริ่ม turn ให้ context lean
214
+ // (mode truncate: ปล่อยให้ loop.ts ตัดต่อ-step เอา; ไม่บีบที่นี่ กัน latency)
215
+ if (estimateTokens(msgsRef.current) > PRE_TURN_COMPACT_TOKENS) {
216
+ const t = await agentTuning().catch(() => null);
217
+ if (t?.compaction === 'summarize') {
218
+ addTurn('system', '⏳ context ยาว — ย่ออัตโนมัติก่อนรอบนี้…');
219
+ msgsRef.current = await summarizeCompact(msgsRef.current, PRE_TURN_COMPACT_TOKENS, makeSummarizer(model, t.summaryModel), 20).catch(() => msgsRef.current);
220
+ }
221
+ }
222
+ // checkpoint สถานะก่อนรัน (ไฟล์ git + ขอบเขตบทสนทนา) → /rewind ย้อนได้
223
+ const ref = await snapshotWorkTree();
224
+ checkpoints.current.push({ ref, turnId: mark.turnId, msgLen: mark.msgLen });
225
+ const ac = new AbortController(); // steering: ให้ Esc/Ctrl+C หยุด stream กลางทางได้
226
+ abortRef.current = ac;
79
227
  setBusy(true);
80
228
  let buf = '';
81
229
  let lastFlush = 0;
82
230
  try {
83
- const { cost, messages } = await runAgent({
231
+ const { cost, messages, text } = await runAgent({
84
232
  model,
85
- prompt: text,
233
+ fallbackModel,
234
+ prompt: promptText,
235
+ images: images.length ? images : undefined,
86
236
  history: msgsRef.current,
87
237
  budgetUsd,
88
238
  permissionMode,
89
239
  approve: requestApproval,
240
+ signal: ac.signal,
90
241
  onEvent: (e) => {
91
242
  if (e.type === 'text') {
92
243
  buf += e.text ?? '';
@@ -104,8 +255,8 @@ export function App({ initialModel, budgetUsd, permissionMode = 'auto', initialH
104
255
  });
105
256
  msgsRef.current = messages;
106
257
  lastCost.current = cost.summary();
107
- addTurn('assistant', buf.trim());
108
- // เซฟ session ทุกรอบ → resume ได้ด้วย sanook -c (เดิม REPL ไม่เคยเซฟ)
258
+ addTurn('assistant', buf.trim() || text.trim());
259
+ // เซฟ session ทุกรอบ → resume ได้ด้วย sanook -c
109
260
  void saveSession({
110
261
  id: sessionId.current,
111
262
  created: sessionCreated.current,
@@ -114,19 +265,59 @@ export function App({ initialModel, budgetUsd, permissionMode = 'auto', initialH
114
265
  cwd: process.cwd(),
115
266
  messages,
116
267
  });
268
+ // worklog เข้า second-brain — vault จำว่าทำอะไรใน session นี้
269
+ void (async () => {
270
+ const brain = await getBrainPath();
271
+ if (brain) {
272
+ await appendBrainWorklog(brain, {
273
+ prompt: promptText,
274
+ summary: cost.summary(),
275
+ model,
276
+ today: new Date().toISOString().slice(0, 10),
277
+ }).catch(() => { });
278
+ }
279
+ })();
117
280
  }
118
281
  catch (err) {
119
- addTurn('system', `ERROR: ${err.message}`);
282
+ if (ac.signal.aborted) {
283
+ // หยุดเอง — เก็บ partial output ไว้ดู, ทิ้ง turn นี้ออกจาก LLM history (msgsRef ไม่อัปเดต)
284
+ if (buf.trim())
285
+ addTurn('assistant', buf.trim());
286
+ addTurn('system', '⊘ หยุด turn แล้ว (ไฟล์ที่ tool แก้ไปแล้วคืนด้วย /rewind ได้)');
287
+ }
288
+ else {
289
+ addTurn('system', `ERROR: ${err.message}`);
290
+ }
120
291
  }
121
292
  finally {
122
293
  setStreaming('');
123
294
  setBusy(false);
295
+ abortRef.current = null;
124
296
  }
297
+ // steering: ข้อความที่พิมพ์ค้างคิวระหว่าง turn → รันต่อทันที (ถ้าไม่ได้ถูกหยุด)
298
+ const next = ac.signal.aborted ? undefined : dequeue();
299
+ if (next)
300
+ void submit(next);
125
301
  }
126
302
  const banner = useMemo(() => _jsx(Banner, { model: initialModel }), [initialModel]);
127
- return (_jsxs(Box, { flexDirection: "column", children: [banner, _jsx(Static, { items: history, children: (turn) => _jsx(TurnView, { turn: turn }, turn.id) }), streaming ? _jsx(Text, { children: streaming }) : null, approvalReq ? (_jsxs(Box, { borderStyle: "round", borderColor: "yellow", paddingX: 1, flexDirection: "column", children: [_jsxs(Text, { color: "yellow", children: ["\u26A0 \u0E02\u0E2D\u0E2D\u0E19\u0E38\u0E21\u0E31\u0E15\u0E34\u0E23\u0E31\u0E19 ", approvalReq.tool] }), _jsx(Text, { children: approvalReq.summary }), _jsx(Text, { color: "gray", children: "\u0E2D\u0E19\u0E38\u0E21\u0E31\u0E15\u0E34? (y = \u0E23\u0E31\u0E19 \u00B7 n = \u0E1B\u0E0F\u0E34\u0E40\u0E2A\u0E18)" })] })) : (_jsxs(Box, { borderStyle: "round", borderColor: "gray", paddingX: 1, children: [_jsx(Text, { color: busy ? 'gray' : 'cyan', children: busy ? '… ' : '› ' }), _jsx(Text, { children: input || (busy ? '' : 'พิมพ์คำสั่ง หรือ /help') })] })), _jsxs(Text, { color: "gray", dimColor: true, children: [' ', "? for shortcuts \u00B7 /help \u00B7 model: ", model, permissionMode === 'ask' ? ' · 🔒 ask-mode' : ''] })] }));
303
+ const costHint = lastCost.current.includes('cost ') ? lastCost.current.split('cost ')[1] : '';
304
+ 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}` : ''] })] }));
305
+ }
306
+ /** input ที่มี cursor (inverse) + placeholder — minimal; รับ input ได้แม้ busy (ต่อคิว) */
307
+ function InputView({ value, cursor, busy }) {
308
+ if (busy && !value)
309
+ return _jsx(Text, { dimColor: true, children: "\u0E01\u0E33\u0E25\u0E31\u0E07\u0E17\u0E33\u0E07\u0E32\u0E19\u2026 Esc/Ctrl+C \u0E2B\u0E22\u0E38\u0E14 \u00B7 \u0E1E\u0E34\u0E21\u0E1E\u0E4C\u0E40\u0E1E\u0E37\u0E48\u0E2D\u0E15\u0E48\u0E2D\u0E04\u0E34\u0E27 (\u23CE)" });
310
+ if (!busy && !value)
311
+ return _jsx(Text, { dimColor: true, children: "\u0E16\u0E32\u0E21\u0E2D\u0E30\u0E44\u0E23\u0E01\u0E47\u0E44\u0E14\u0E49 \u2014 /help \u0E14\u0E39\u0E04\u0E33\u0E2A\u0E31\u0E48\u0E07 \u00B7 /tools \u0E14\u0E39 tools \u00B7 @\u0E44\u0E1F\u0E25\u0E4C \u0E41\u0E19\u0E1A context/\u0E23\u0E39\u0E1B" });
312
+ const before = value.slice(0, cursor);
313
+ const at = value.slice(cursor, cursor + 1) || ' ';
314
+ const after = value.slice(cursor + 1);
315
+ 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] }));
128
316
  }
129
317
  function TurnView({ turn }) {
130
- const color = turn.role === 'user' ? 'cyan' : turn.role === 'system' ? 'yellow' : undefined;
131
- return (_jsxs(Text, { color: color, children: [turn.role === 'user' ? '› ' : '', turn.text] }));
318
+ if (turn.role === 'system')
319
+ return _jsx(Text, { dimColor: true, children: turn.text });
320
+ if (turn.role === 'user')
321
+ return (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "cyan", children: "\u203A " }), _jsx(Text, { color: "cyan", children: turn.text })] }));
322
+ return (_jsx(Box, { marginTop: 1, children: _jsx(Text, { children: turn.text }) }));
132
323
  }
package/dist/ui/banner.js CHANGED
@@ -1,20 +1,15 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Box, Text, useStdout } from 'ink';
2
+ import { Box, Text } from 'ink';
3
3
  import Gradient from 'ink-gradient';
4
- import BigText from 'ink-big-text';
5
4
  import { homedir } from 'node:os';
6
5
  import { readFileSync } from 'node:fs';
6
+ import { BRAND } from '../brand.js';
7
7
  // gradient ของ Sanook: เขียว → ส้ม → ฟ้า (สนุก = สดใส)
8
8
  const SANOOK_GRADIENT = ['#22C55E', '#F97316', '#38BDF8'];
9
9
  // version จาก package.json (single source of truth) — กัน default drift เหมือน bin.ts
10
10
  const VERSION = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version;
11
- /** welcome banner — big ASCII + gradient + info line (responsive ตามความกว้าง terminal) */
11
+ /** welcome banner — minimal: gradient wordmark + meta บรรทัดเดียว (terminal-first, ไม่รก) */
12
12
  export function Banner({ model, version = VERSION, account = 'BYOK', cwd }) {
13
- const { stdout } = useStdout();
14
- const columns = stdout?.columns ?? 80;
15
13
  const dir = (cwd ?? process.cwd()).replace(homedir(), '~');
16
- // จอกว้าง = "Sanook AI" ใหญ่, แคบ = "Sanook" / เล็กลง
17
- const bigText = columns >= 92 ? 'Sanook AI' : 'Sanook';
18
- const font = columns >= 48 ? 'block' : 'tiny';
19
- return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Gradient, { colors: SANOOK_GRADIENT, children: _jsx(BigText, { text: bigText, font: font }) }), _jsxs(Box, { marginTop: -1, marginLeft: 1, flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { bold: true, color: "cyan", children: "Sanook AI CLI" }), _jsxs(Text, { color: "gray", children: [" v", version, " \u00B7 terminal coding agent \u00B7 BYOK"] })] }), _jsxs(Text, { color: "gray", children: [_jsx(Text, { color: "green", children: "\u25CF" }), " ", model, ' ', "account: ", account, ' ', "cwd: ", dir] })] })] }));
14
+ return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsx(Gradient, { colors: SANOOK_GRADIENT, children: _jsx(Text, { bold: true, children: BRAND.cliName }) }), _jsxs(Text, { dimColor: true, children: [" v", version, " \u00B7 terminal coding agent \u00B7 ", account] })] }), _jsxs(Text, { dimColor: true, children: [_jsx(Text, { color: "green", children: "\u25CF" }), " ", model, " \u00B7 ", dir] })] }));
20
15
  }
@@ -0,0 +1,30 @@
1
+ import { readFileSync, appendFileSync, mkdirSync } from 'node:fs';
2
+ import { appHomePath, persistenceEnabled } from '../brand.js';
3
+ // prompt history แบบ persist ข้าม session (เลียน shell history) — เก็บที่ ~/.sanook/history
4
+ const HISTORY_PATH = appHomePath('history');
5
+ const MAX_ENTRIES = 500;
6
+ /** โหลด prompt เก่า (เก่า→ใหม่) สำหรับ Up/Down navigation ใน REPL */
7
+ export function loadHistory() {
8
+ try {
9
+ const lines = readFileSync(HISTORY_PATH, 'utf8').split('\n').filter(Boolean);
10
+ return lines.slice(-MAX_ENTRIES);
11
+ }
12
+ catch {
13
+ return [];
14
+ }
15
+ }
16
+ /** append 1 prompt (ข้ามถ้าซ้ำกับอันล่าสุด / เป็น slash command / ว่าง) */
17
+ export function appendHistory(prompt, last) {
18
+ if (!persistenceEnabled())
19
+ return;
20
+ const p = prompt.trim();
21
+ if (!p || p === last)
22
+ return;
23
+ try {
24
+ mkdirSync(appHomePath(), { recursive: true });
25
+ appendFileSync(HISTORY_PATH, `${p.replace(/\n/g, ' ')}\n`, { mode: 0o600 });
26
+ }
27
+ catch {
28
+ /* เขียนไม่ได้ = ไม่เป็นไร (history เป็น nice-to-have) */
29
+ }
30
+ }
@@ -0,0 +1,44 @@
1
+ import { readFile, realpath } from 'node:fs/promises';
2
+ import { resolve, extname } from 'node:path';
3
+ import { checkReadPath } from '../tools/permission.js';
4
+ // @-file mentions: "@path" ใน prompt → inline เนื้อหาไฟล์ (text) หรือแนบเป็น image (รูป)
5
+ // ลด tool round-trip (agent ไม่ต้อง read_file เอง) + เปิดทาง vision input
6
+ const IMAGE_EXT = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp']);
7
+ const MENTION_RE = /(?:^|\s)@([^\s]+)/g;
8
+ const MAX_INLINE = 60_000;
9
+ /** แตก @mention ใน prompt: text file → inline, image → คืน path ไปแนบ, ที่เหลือคงไว้ */
10
+ export async function expandMentions(input) {
11
+ const mentions = [...input.matchAll(MENTION_RE)].map((m) => m[1]);
12
+ if (!mentions.length)
13
+ return { text: input, images: [], errors: [] };
14
+ const images = [];
15
+ const errors = [];
16
+ const inlined = [];
17
+ for (const rel of [...new Set(mentions)]) {
18
+ const abs = resolve(rel);
19
+ // canonicalize ก่อนเช็ก extension → symlink ที่ชื่อไม่มีนามสกุลแต่ชี้ไปรูป ก็จับเป็น image ถูก
20
+ const real = await realpath(abs).catch(() => abs);
21
+ if (IMAGE_EXT.has(extname(real).toLowerCase())) {
22
+ const guard = await checkReadPath(real);
23
+ if (guard.ok)
24
+ images.push(real);
25
+ else
26
+ errors.push(`@${rel} (${guard.reason})`);
27
+ continue;
28
+ }
29
+ const guard = await checkReadPath(real);
30
+ if (!guard.ok) {
31
+ errors.push(`@${rel} (${guard.reason})`);
32
+ continue;
33
+ }
34
+ try {
35
+ const content = (await readFile(real, 'utf8')).slice(0, MAX_INLINE);
36
+ inlined.push(`<file path="${rel}">\n${content}\n</file>`);
37
+ }
38
+ catch (e) {
39
+ errors.push(`@${rel} (${e.message})`);
40
+ }
41
+ }
42
+ const text = inlined.length ? `${input}\n\n${inlined.join('\n\n')}` : input;
43
+ return { text, images, errors };
44
+ }
package/dist/ui/setup.js CHANGED
@@ -1,9 +1,10 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
2
  import { useState, useEffect } from 'react';
3
3
  import { Box, Text } from 'ink';
4
4
  import { Select, PasswordInput } from '@inkjs/ui';
5
- import { PROVIDERS } from '../providers/registry.js';
5
+ import { PROVIDERS, consoleUrl } from '../providers/registry.js';
6
6
  import { listRemoteModels, mergeModelOptions } from '../providers/models.js';
7
+ import { BRAND } from '../brand.js';
7
8
  /** first-run setup wizard: เลือก provider → ใส่ API key → เลือก model → เสนอสร้าง second-brain */
8
9
  export function SetupWizard({ onComplete }) {
9
10
  const [step, setStep] = useState('provider');
@@ -28,10 +29,10 @@ export function SetupWizard({ onComplete }) {
28
29
  }, [step, cfg, key]);
29
30
  const modelOptions = cfg ? mergeModelOptions(cfg, remote) : [];
30
31
  const finish = (createBrain) => onComplete({ provider, model, envVar: cfg?.envVar ?? '', key, createBrain });
31
- return (_jsxs(Box, { flexDirection: "column", gap: 1, marginY: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "\u2699 \u0E15\u0E31\u0E49\u0E07\u0E04\u0E48\u0E32 Sanook AI CLI (\u0E04\u0E23\u0E31\u0E49\u0E07\u0E41\u0E23\u0E01)" }), step === 'provider' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "1. \u0E40\u0E25\u0E37\u0E2D\u0E01 AI provider:" }), _jsx(Select, { options: providerOptions, onChange: (v) => {
32
+ return (_jsxs(Box, { flexDirection: "column", gap: 1, marginY: 1, children: [_jsxs(Text, { bold: true, color: "cyan", children: ["\u2699 \u0E15\u0E31\u0E49\u0E07\u0E04\u0E48\u0E32 ", BRAND.bannerTitle, " (\u0E04\u0E23\u0E31\u0E49\u0E07\u0E41\u0E23\u0E01)"] }), step === 'provider' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "1. \u0E40\u0E25\u0E37\u0E2D\u0E01 AI provider:" }), _jsx(Select, { options: providerOptions, onChange: (v) => {
32
33
  setProvider(v);
33
34
  setStep(PROVIDERS[v].requiresKey ? 'key' : 'model');
34
- } })] })), step === 'key' && cfg && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: ["2. \u0E27\u0E32\u0E07 API key \u0E02\u0E2D\u0E07 ", cfg.label, ":"] }), _jsx(Text, { color: "gray", children: " (key \u0E15\u0E23\u0E07\u0E08\u0E32\u0E01 console \u0E02\u0E2D\u0E07\u0E04\u0E48\u0E32\u0E22 \u2014 \u0E2B\u0E49\u0E32\u0E21 OAuth/subscription token)" }), _jsx(PasswordInput, { placeholder: cfg.envVar, onSubmit: (v) => {
35
+ } })] })), step === 'key' && cfg && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: ["2. \u0E27\u0E32\u0E07 API key \u0E02\u0E2D\u0E07 ", cfg.label, ":"] }), consoleUrl(provider) ? (_jsxs(Text, { color: "cyan", children: [" \u2192 \u0E40\u0E2D\u0E32 key \u0E17\u0E35\u0E48: ", consoleUrl(provider)] })) : null, _jsx(Text, { color: "gray", children: " (API key \u0E15\u0E23\u0E07\u0E08\u0E32\u0E01 console \u2014 \u0E2B\u0E49\u0E32\u0E21 OAuth/subscription token)" }), _jsx(PasswordInput, { placeholder: cfg.envVar, onSubmit: (v) => {
35
36
  setKey(v.trim());
36
37
  setStep('model');
37
38
  } })] })), step === 'model' &&
@@ -41,6 +42,6 @@ export function SetupWizard({ onComplete }) {
41
42
  setStep('brain-offer');
42
43
  } })] }))), step === 'brain-offer' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "4. \u0E2A\u0E23\u0E49\u0E32\u0E07 \"second brain\" workspace (Obsidian) \u0E2A\u0E33\u0E2B\u0E23\u0E31\u0E1A\u0E08\u0E31\u0E14\u0E40\u0E01\u0E47\u0E1A\u0E07\u0E32\u0E19 + \u0E04\u0E27\u0E32\u0E21\u0E08\u0E33 AI?" }), _jsx(Select, { options: [
43
44
  { label: 'สร้างเลย — ตอบไม่กี่ข้อ (ชื่อ + ที่เก็บ)', value: 'yes' },
44
- { label: 'ข้ามไปก่อน (สั่ง sanook brain init ทีหลังได้)', value: 'no' },
45
+ { label: `ข้ามไปก่อน (สั่ง ${BRAND.cliName} brain init ทีหลังได้)`, value: 'no' },
45
46
  ], onChange: (v) => finish(v === 'yes') })] }))] }));
46
47
  }
@@ -0,0 +1,83 @@
1
+ import { useState, useRef } from 'react';
2
+ export function useEditor(history) {
3
+ const [value, setValue] = useState('');
4
+ const [cursor, setCursor] = useState(0);
5
+ const histIndex = useRef(null); // null = กำลังแก้ draft (ไม่ได้อยู่ในประวัติ)
6
+ const draft = useRef('');
7
+ const set = (v, c = v.length) => {
8
+ setValue(v);
9
+ setCursor(Math.max(0, Math.min(c, v.length)));
10
+ };
11
+ const reset = () => {
12
+ histIndex.current = null;
13
+ set('');
14
+ };
15
+ const insert = (s) => set(value.slice(0, cursor) + s + value.slice(cursor), cursor + s.length);
16
+ const historyPrev = () => {
17
+ if (!history.length)
18
+ return;
19
+ if (histIndex.current === null) {
20
+ draft.current = value;
21
+ histIndex.current = history.length - 1;
22
+ }
23
+ else {
24
+ histIndex.current = Math.max(0, histIndex.current - 1);
25
+ }
26
+ set(history[histIndex.current]);
27
+ };
28
+ const historyNext = () => {
29
+ if (histIndex.current === null)
30
+ return;
31
+ if (histIndex.current >= history.length - 1) {
32
+ histIndex.current = null;
33
+ set(draft.current);
34
+ }
35
+ else {
36
+ histIndex.current += 1;
37
+ set(history[histIndex.current]);
38
+ }
39
+ };
40
+ const handleKey = (input, key) => {
41
+ if (key.return) {
42
+ // Alt/Option+Enter หรือบรรทัดลงท้าย "\" → ขึ้นบรรทัดใหม่ (multiline) ไม่ submit
43
+ if (key.meta)
44
+ return insert('\n'), 'handled';
45
+ if (value.slice(0, cursor).endsWith('\\'))
46
+ return set(value.slice(0, cursor - 1) + '\n' + value.slice(cursor), cursor), 'handled';
47
+ return 'submit';
48
+ }
49
+ if (key.upArrow)
50
+ return historyPrev(), 'handled';
51
+ if (key.downArrow)
52
+ return historyNext(), 'handled';
53
+ if (key.leftArrow)
54
+ return setCursor(Math.max(0, cursor - 1)), 'handled';
55
+ if (key.rightArrow)
56
+ return setCursor(Math.min(value.length, cursor + 1)), 'handled';
57
+ if (key.ctrl) {
58
+ switch (input) {
59
+ case 'a': return setCursor(0), 'handled';
60
+ case 'e': return setCursor(value.length), 'handled';
61
+ case 'u': return set(value.slice(cursor), 0), 'handled'; // ลบจากต้นบรรทัดถึง cursor
62
+ case 'k': return set(value.slice(0, cursor), cursor), 'handled'; // ลบจาก cursor ถึงท้าย
63
+ case 'w': { // ลบ word ก่อน cursor (รวมกรณีเหลือแต่ whitespace)
64
+ const left = value.slice(0, cursor).replace(/\s+$|\s*\S+\s*$/, '');
65
+ return set(left + value.slice(cursor), left.length), 'handled';
66
+ }
67
+ case 'c': return 'interrupt';
68
+ default: return 'handled';
69
+ }
70
+ }
71
+ if (key.backspace || key.delete) {
72
+ if (cursor === 0)
73
+ return 'handled';
74
+ return set(value.slice(0, cursor - 1) + value.slice(cursor), cursor - 1), 'handled';
75
+ }
76
+ if (input && !key.meta) {
77
+ histIndex.current = null; // เริ่มพิมพ์ = ออกจากโหมดดูประวัติ
78
+ return insert(input), 'handled';
79
+ }
80
+ return 'none';
81
+ };
82
+ return { value, cursor, setValue: (v) => set(v), reset, handleKey };
83
+ }