@kkelly-offical/kkcode 0.1.2

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 (196) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +445 -0
  3. package/package.json +46 -0
  4. package/src/agent/agent.mjs +170 -0
  5. package/src/agent/custom-agent-loader.mjs +158 -0
  6. package/src/agent/generator.mjs +115 -0
  7. package/src/agent/prompt/architect.txt +36 -0
  8. package/src/agent/prompt/build-fixer.txt +71 -0
  9. package/src/agent/prompt/build.txt +101 -0
  10. package/src/agent/prompt/compaction.txt +12 -0
  11. package/src/agent/prompt/explore.txt +29 -0
  12. package/src/agent/prompt/guide.txt +40 -0
  13. package/src/agent/prompt/longagent.txt +178 -0
  14. package/src/agent/prompt/plan.txt +50 -0
  15. package/src/agent/prompt/researcher.txt +23 -0
  16. package/src/agent/prompt/reviewer.txt +44 -0
  17. package/src/agent/prompt/security-reviewer.txt +62 -0
  18. package/src/agent/prompt/tdd-guide.txt +84 -0
  19. package/src/agent/prompt/title.txt +8 -0
  20. package/src/command/custom-commands.mjs +57 -0
  21. package/src/commands/agent.mjs +71 -0
  22. package/src/commands/audit.mjs +77 -0
  23. package/src/commands/background.mjs +86 -0
  24. package/src/commands/chat.mjs +114 -0
  25. package/src/commands/command.mjs +41 -0
  26. package/src/commands/config.mjs +44 -0
  27. package/src/commands/doctor.mjs +148 -0
  28. package/src/commands/hook.mjs +29 -0
  29. package/src/commands/init.mjs +141 -0
  30. package/src/commands/longagent.mjs +100 -0
  31. package/src/commands/mcp.mjs +89 -0
  32. package/src/commands/permission.mjs +36 -0
  33. package/src/commands/prompt.mjs +42 -0
  34. package/src/commands/review.mjs +266 -0
  35. package/src/commands/rule.mjs +34 -0
  36. package/src/commands/session.mjs +235 -0
  37. package/src/commands/theme.mjs +98 -0
  38. package/src/commands/usage.mjs +91 -0
  39. package/src/config/defaults.mjs +195 -0
  40. package/src/config/import-config.mjs +76 -0
  41. package/src/config/load-config.mjs +76 -0
  42. package/src/config/schema.mjs +509 -0
  43. package/src/context.mjs +40 -0
  44. package/src/core/constants.mjs +46 -0
  45. package/src/core/errors.mjs +57 -0
  46. package/src/core/events.mjs +29 -0
  47. package/src/core/types.mjs +57 -0
  48. package/src/github/api.mjs +78 -0
  49. package/src/github/auth.mjs +286 -0
  50. package/src/github/flow.mjs +298 -0
  51. package/src/github/workspace.mjs +212 -0
  52. package/src/index.mjs +82 -0
  53. package/src/knowledge/api-design.txt +9 -0
  54. package/src/knowledge/cpp.txt +10 -0
  55. package/src/knowledge/docker.txt +10 -0
  56. package/src/knowledge/dotnet.txt +9 -0
  57. package/src/knowledge/electron.txt +10 -0
  58. package/src/knowledge/flutter.txt +10 -0
  59. package/src/knowledge/go.txt +9 -0
  60. package/src/knowledge/graphql.txt +10 -0
  61. package/src/knowledge/java.txt +9 -0
  62. package/src/knowledge/kotlin.txt +10 -0
  63. package/src/knowledge/loader.mjs +125 -0
  64. package/src/knowledge/next.txt +8 -0
  65. package/src/knowledge/node.txt +8 -0
  66. package/src/knowledge/nuxt.txt +9 -0
  67. package/src/knowledge/php.txt +10 -0
  68. package/src/knowledge/python.txt +10 -0
  69. package/src/knowledge/react-native.txt +10 -0
  70. package/src/knowledge/react.txt +9 -0
  71. package/src/knowledge/ruby.txt +11 -0
  72. package/src/knowledge/rust.txt +9 -0
  73. package/src/knowledge/svelte.txt +9 -0
  74. package/src/knowledge/swift.txt +10 -0
  75. package/src/knowledge/tailwind.txt +10 -0
  76. package/src/knowledge/testing.txt +8 -0
  77. package/src/knowledge/typescript.txt +8 -0
  78. package/src/knowledge/vue.txt +9 -0
  79. package/src/mcp/client-http.mjs +157 -0
  80. package/src/mcp/client-sse.mjs +286 -0
  81. package/src/mcp/client-stdio.mjs +451 -0
  82. package/src/mcp/registry.mjs +394 -0
  83. package/src/mcp/stdio-framing.mjs +127 -0
  84. package/src/orchestration/background-manager.mjs +358 -0
  85. package/src/orchestration/background-worker.mjs +245 -0
  86. package/src/orchestration/longagent-manager.mjs +116 -0
  87. package/src/orchestration/stage-scheduler.mjs +489 -0
  88. package/src/orchestration/subagent-router.mjs +62 -0
  89. package/src/orchestration/task-scheduler.mjs +74 -0
  90. package/src/permission/engine.mjs +92 -0
  91. package/src/permission/exec-policy.mjs +372 -0
  92. package/src/permission/prompt.mjs +39 -0
  93. package/src/permission/rules.mjs +120 -0
  94. package/src/permission/workspace-trust.mjs +44 -0
  95. package/src/plugin/builtin-hooks/console-warn.mjs +41 -0
  96. package/src/plugin/builtin-hooks/extract-patterns.mjs +75 -0
  97. package/src/plugin/builtin-hooks/post-edit-format.mjs +57 -0
  98. package/src/plugin/builtin-hooks/post-edit-typecheck.mjs +61 -0
  99. package/src/plugin/builtin-hooks/strategic-compaction.mjs +38 -0
  100. package/src/plugin/hook-bus.mjs +154 -0
  101. package/src/provider/anthropic.mjs +389 -0
  102. package/src/provider/ollama.mjs +236 -0
  103. package/src/provider/openai-compatible.mjs +1 -0
  104. package/src/provider/openai.mjs +339 -0
  105. package/src/provider/retry-policy.mjs +68 -0
  106. package/src/provider/router.mjs +228 -0
  107. package/src/provider/sse.mjs +91 -0
  108. package/src/repl.mjs +2929 -0
  109. package/src/review/diff-parser.mjs +36 -0
  110. package/src/review/rejection-queue.mjs +62 -0
  111. package/src/review/review-store.mjs +21 -0
  112. package/src/review/risk-score.mjs +61 -0
  113. package/src/rules/load-rules.mjs +64 -0
  114. package/src/runtime.mjs +1 -0
  115. package/src/session/checkpoint.mjs +239 -0
  116. package/src/session/compaction.mjs +276 -0
  117. package/src/session/engine.mjs +225 -0
  118. package/src/session/instinct-manager.mjs +172 -0
  119. package/src/session/instruction-loader.mjs +25 -0
  120. package/src/session/longagent-plan.mjs +329 -0
  121. package/src/session/longagent-scaffold.mjs +100 -0
  122. package/src/session/longagent.mjs +1462 -0
  123. package/src/session/loop.mjs +905 -0
  124. package/src/session/memory-loader.mjs +75 -0
  125. package/src/session/project-context.mjs +367 -0
  126. package/src/session/prompt/anthropic.txt +151 -0
  127. package/src/session/prompt/beast.txt +37 -0
  128. package/src/session/prompt/max-steps.txt +6 -0
  129. package/src/session/prompt/plan.txt +9 -0
  130. package/src/session/prompt/qwen.txt +46 -0
  131. package/src/session/prompt-loader.mjs +18 -0
  132. package/src/session/recovery.mjs +52 -0
  133. package/src/session/store.mjs +503 -0
  134. package/src/session/system-prompt.mjs +260 -0
  135. package/src/session/task-validator.mjs +266 -0
  136. package/src/session/usability-gates.mjs +379 -0
  137. package/src/skill/builtin/backend-patterns.mjs +123 -0
  138. package/src/skill/builtin/commit.mjs +64 -0
  139. package/src/skill/builtin/debug.mjs +45 -0
  140. package/src/skill/builtin/frontend-patterns.mjs +120 -0
  141. package/src/skill/builtin/frontend.mjs +188 -0
  142. package/src/skill/builtin/init.mjs +220 -0
  143. package/src/skill/builtin/review.mjs +49 -0
  144. package/src/skill/builtin/security-checklist.mjs +80 -0
  145. package/src/skill/builtin/tdd.mjs +54 -0
  146. package/src/skill/generator.mjs +113 -0
  147. package/src/skill/registry.mjs +336 -0
  148. package/src/storage/audit-store.mjs +83 -0
  149. package/src/storage/event-log.mjs +82 -0
  150. package/src/storage/ghost-commit-store.mjs +235 -0
  151. package/src/storage/json-store.mjs +53 -0
  152. package/src/storage/paths.mjs +148 -0
  153. package/src/theme/color.mjs +64 -0
  154. package/src/theme/default-theme.mjs +29 -0
  155. package/src/theme/load-theme.mjs +71 -0
  156. package/src/theme/markdown.mjs +135 -0
  157. package/src/theme/schema.mjs +45 -0
  158. package/src/theme/status-bar.mjs +158 -0
  159. package/src/tool/audit-wrapper.mjs +38 -0
  160. package/src/tool/edit-transaction.mjs +126 -0
  161. package/src/tool/executor.mjs +109 -0
  162. package/src/tool/file-lock-manager.mjs +85 -0
  163. package/src/tool/git-auto.mjs +545 -0
  164. package/src/tool/git-full-auto.mjs +478 -0
  165. package/src/tool/image-util.mjs +276 -0
  166. package/src/tool/prompt/background_cancel.txt +1 -0
  167. package/src/tool/prompt/background_output.txt +1 -0
  168. package/src/tool/prompt/bash.txt +71 -0
  169. package/src/tool/prompt/codesearch.txt +18 -0
  170. package/src/tool/prompt/edit.txt +27 -0
  171. package/src/tool/prompt/enter_plan.txt +74 -0
  172. package/src/tool/prompt/exit_plan.txt +62 -0
  173. package/src/tool/prompt/glob.txt +33 -0
  174. package/src/tool/prompt/grep.txt +43 -0
  175. package/src/tool/prompt/list.txt +8 -0
  176. package/src/tool/prompt/multiedit.txt +20 -0
  177. package/src/tool/prompt/notebookedit.txt +21 -0
  178. package/src/tool/prompt/patch.txt +24 -0
  179. package/src/tool/prompt/question.txt +44 -0
  180. package/src/tool/prompt/read.txt +40 -0
  181. package/src/tool/prompt/task.txt +83 -0
  182. package/src/tool/prompt/todowrite.txt +117 -0
  183. package/src/tool/prompt/webfetch.txt +38 -0
  184. package/src/tool/prompt/websearch.txt +43 -0
  185. package/src/tool/prompt/write.txt +38 -0
  186. package/src/tool/prompt-loader.mjs +18 -0
  187. package/src/tool/question-prompt.mjs +86 -0
  188. package/src/tool/registry.mjs +1309 -0
  189. package/src/tool/task-tool.mjs +28 -0
  190. package/src/ui/activity-renderer.mjs +410 -0
  191. package/src/ui/repl-dashboard.mjs +357 -0
  192. package/src/usage/pricing.mjs +121 -0
  193. package/src/usage/usage-meter.mjs +113 -0
  194. package/src/util/git.mjs +496 -0
  195. package/src/util/template.mjs +10 -0
  196. package/src/util/yaml.mjs +100 -0
package/src/repl.mjs ADDED
@@ -0,0 +1,2929 @@
1
+ import { stdin as input, stdout as output } from "node:process"
2
+ import { createInterface } from "node:readline/promises"
3
+ import { emitKeypressEvents } from "node:readline"
4
+ import { readFile, writeFile, mkdir } from "node:fs/promises"
5
+ import { homedir } from "node:os"
6
+ import { basename, dirname, join } from "node:path"
7
+ import YAML from "yaml"
8
+ import { buildContext, printContextWarnings } from "./context.mjs"
9
+ import { executeTurn, newSessionId, resolveMode } from "./session/engine.mjs"
10
+ import { renderStatusBar } from "./theme/status-bar.mjs"
11
+ import { listProviders } from "./provider/router.mjs"
12
+ import { loadCustomCommands, applyCommandTemplate } from "./command/custom-commands.mjs"
13
+ import { SkillRegistry } from "./skill/registry.mjs"
14
+ import { renderMarkdown } from "./theme/markdown.mjs"
15
+ import { listSessions, getConversationHistory } from "./session/store.mjs"
16
+ import { compactSession } from "./session/compaction.mjs"
17
+ import { ToolRegistry } from "./tool/registry.mjs"
18
+ import { McpRegistry } from "./mcp/registry.mjs"
19
+ import { HookBus, initHookBus } from "./plugin/hook-bus.mjs"
20
+ import { renderReplDashboard, renderReplLogo, renderStartupHint } from "./ui/repl-dashboard.mjs"
21
+ import { paint } from "./theme/color.mjs"
22
+ import { PermissionEngine } from "./permission/engine.mjs"
23
+ import { setPermissionPromptHandler } from "./permission/prompt.mjs"
24
+ import { setQuestionPromptHandler } from "./tool/question-prompt.mjs"
25
+ import { createActivityRenderer, formatPlanProgress } from "./ui/activity-renderer.mjs"
26
+ import { EventBus } from "./core/events.mjs"
27
+ import { EVENT_TYPES } from "./core/constants.mjs"
28
+ import { extractImageRefs, buildContentBlocks, readClipboardImage, readClipboardText } from "./tool/image-util.mjs"
29
+ import { generateSkill, saveSkillGlobal } from "./skill/generator.mjs"
30
+ import { userConfigCandidates, projectConfigCandidates, memoryFilePath } from "./storage/paths.mjs"
31
+ import { persistTrust, revokeTrust } from "./permission/workspace-trust.mjs"
32
+
33
+ const HIST_DIR = join(homedir(), ".kkcode")
34
+ const HIST_FILE = join(HIST_DIR, "repl_history")
35
+ const HIST_SIZE = 500
36
+ const MAX_TUI_LOG_LINES = 1200
37
+ const MAX_TUI_SUGGESTIONS = 5
38
+ const MAX_MODEL_PICKER_VISIBLE = 8
39
+ const TUI_FRAME_MS = 16
40
+ const ANSI_RE = /\x1B\[[0-9;]*m/g
41
+ const SCROLL_PAGE_RATIO = 0.75
42
+ const MODE_CYCLE_ORDER = ["longagent", "plan", "ask", "agent"]
43
+ const BUSY_SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
44
+
45
+ function clipBusy(text, max) {
46
+ const s = String(text || "").trim().split("\n")[0]
47
+ return s.length > max ? s.slice(0, max - 3) + "..." : s
48
+ }
49
+
50
+ function formatBusyToolDetail(toolName, args) {
51
+ if (!args) return ""
52
+ switch (toolName) {
53
+ case "bash": return args.command ? paint(` ${clipBusy(args.command, 60)}`, null, { dim: true }) : ""
54
+ case "read": return args.path ? paint(` ${clipBusy(args.path, 60)}`, null, { dim: true }) : ""
55
+ case "write": return args.path ? paint(` ${clipBusy(args.path, 60)}`, null, { dim: true }) : ""
56
+ case "edit": return args.path ? paint(` ${clipBusy(args.path, 60)}`, null, { dim: true }) : ""
57
+ case "notebookedit": return args.path ? paint(` ${clipBusy(args.path, 50)} cell ${args.cell_number ?? 0}`, null, { dim: true }) : ""
58
+ case "grep": return args.pattern ? paint(` ${clipBusy(args.pattern, 40)}`, null, { dim: true }) : ""
59
+ case "glob": return args.pattern ? paint(` ${clipBusy(args.pattern, 40)}`, null, { dim: true }) : ""
60
+ case "patch": return args.path ? paint(` ${clipBusy(args.path, 40)} L${args.start_line || "?"}-${args.end_line || "?"}`, null, { dim: true }) : ""
61
+ case "task": return args.description ? paint(` ${clipBusy(args.description, 50)}`, null, { dim: true }) : ""
62
+ case "enter_plan": return args.reason ? paint(` ${clipBusy(args.reason, 50)}`, null, { dim: true }) : paint(" planning...", null, { dim: true })
63
+ case "exit_plan": return paint(" submitting plan...", null, { dim: true })
64
+ default: return ""
65
+ }
66
+ }
67
+
68
+ const BUILTIN_SLASH = [
69
+ { name: "help", desc: "show help" },
70
+ { name: "dash", desc: "redraw dashboard" },
71
+ { name: "clear", desc: "clear terminal" },
72
+ { name: "new", desc: "new session" },
73
+ { name: "resume", desc: "resume session" },
74
+ { name: "history", desc: "list sessions" },
75
+ { name: "mode", desc: "switch mode" },
76
+ { name: "provider", desc: "switch provider" },
77
+ { name: "model", desc: "open model picker" },
78
+ { name: "permission", desc: "permission policy / cache" },
79
+ { name: "status", desc: "runtime state" },
80
+ { name: "commands", desc: "list custom slash commands" },
81
+ { name: "reload", desc: "reload custom commands" },
82
+ { name: "paste", desc: "paste image from clipboard" },
83
+ { name: "keys", desc: "show key map" },
84
+ { name: "session", desc: "show session id" },
85
+ { name: "ask", desc: "switch to ask mode" },
86
+ { name: "plan", desc: "switch to plan mode" },
87
+ { name: "agent", desc: "switch to agent mode" },
88
+ { name: "longagent", desc: "switch to longagent mode" },
89
+ { name: "create-skill", desc: "generate a new skill via AI" },
90
+ { name: "create-agent", desc: "generate a new sub-agent via AI" },
91
+ { name: "trust", desc: "trust this workspace" },
92
+ { name: "untrust", desc: "revoke workspace trust" },
93
+ { name: "exit", desc: "quit" }
94
+ ]
95
+
96
+ function stripAnsi(text) {
97
+ return String(text || "").replace(ANSI_RE, "")
98
+ }
99
+
100
+ function isFullWidthCodePoint(code) {
101
+ if (Number.isNaN(code)) return false
102
+ if (
103
+ code >= 0x1100 && (
104
+ code <= 0x115f ||
105
+ code === 0x2329 || code === 0x232a ||
106
+ (code >= 0x2e80 && code <= 0xa4cf && code !== 0x303f) ||
107
+ (code >= 0xac00 && code <= 0xd7a3) ||
108
+ (code >= 0xf900 && code <= 0xfaff) ||
109
+ (code >= 0xfe10 && code <= 0xfe19) ||
110
+ (code >= 0xfe30 && code <= 0xfe6f) ||
111
+ (code >= 0xff00 && code <= 0xff60) ||
112
+ (code >= 0xffe0 && code <= 0xffe6) ||
113
+ (code >= 0x1f300 && code <= 0x1f64f) ||
114
+ (code >= 0x1f900 && code <= 0x1f9ff) ||
115
+ (code >= 0x20000 && code <= 0x3fffd)
116
+ )
117
+ ) return true
118
+ return false
119
+ }
120
+
121
+ function displayWidth(text) {
122
+ const raw = stripAnsi(text)
123
+ let width = 0
124
+ for (const ch of raw) {
125
+ const code = ch.codePointAt(0)
126
+ width += isFullWidthCodePoint(code) ? 2 : 1
127
+ }
128
+ return width
129
+ }
130
+
131
+ function clipPlainByWidth(text, maxWidth) {
132
+ if (maxWidth <= 0) return ""
133
+ let out = ""
134
+ let used = 0
135
+ for (const ch of String(text || "")) {
136
+ const w = isFullWidthCodePoint(ch.codePointAt(0)) ? 2 : 1
137
+ if (used + w > maxWidth) break
138
+ out += ch
139
+ used += w
140
+ }
141
+ return out
142
+ }
143
+
144
+ function padRight(text, width) {
145
+ const raw = stripAnsi(text)
146
+ const used = displayWidth(raw)
147
+ if (used >= width) return clipPlainByWidth(raw, width)
148
+ return raw + " ".repeat(width - used)
149
+ }
150
+
151
+ function clipAnsiLine(text, width) {
152
+ const raw = stripAnsi(text)
153
+ const used = displayWidth(raw)
154
+ if (used <= width) return `${String(text || "")}${" ".repeat(Math.max(0, width - used))}`
155
+ if (width <= 1) return clipPlainByWidth(raw, Math.max(0, width))
156
+ return `${clipPlainByWidth(raw, width - 1)}~`
157
+ }
158
+
159
+ function wrapPlainLine(text, width) {
160
+ const raw = stripAnsi(text)
161
+ if (width <= 0) return [""]
162
+ if (!raw) return [""]
163
+ const out = []
164
+ let rest = raw
165
+ while (displayWidth(rest) > width) {
166
+ const chunk = clipPlainByWidth(rest, width)
167
+ out.push(chunk)
168
+ rest = rest.slice(chunk.length)
169
+ }
170
+ out.push(rest)
171
+ return out
172
+ }
173
+
174
+ function wrapLogLines(lines, width, maxRows = null) {
175
+ const wrapped = []
176
+ for (const line of lines) {
177
+ const parts = wrapPlainLine(line, width)
178
+ for (const part of parts) wrapped.push(part)
179
+ }
180
+ if (!Number.isInteger(maxRows) || maxRows < 0) return wrapped
181
+ if (wrapped.length <= maxRows) return wrapped
182
+ return wrapped.slice(wrapped.length - maxRows)
183
+ }
184
+
185
+ function frameTop(width, color) {
186
+ return paint(`┌${"─".repeat(Math.max(1, width - 2))}┐`, color)
187
+ }
188
+
189
+ function frameBottom(width, color) {
190
+ return paint(`└${"─".repeat(Math.max(1, width - 2))}┘`, color)
191
+ }
192
+
193
+ function frameDivider(width, color) {
194
+ return paint(`├${"─".repeat(Math.max(1, width - 2))}┤`, color)
195
+ }
196
+
197
+ function frameRow(content, width, color) {
198
+ const inner = Math.max(1, width - 4)
199
+ const left = paint("│ ", color)
200
+ const right = paint(" │", color)
201
+ return `${left}${clipAnsiLine(content, inner)}${right}`
202
+ }
203
+
204
+ function pageSize(rows) {
205
+ return Math.max(1, Math.floor(rows * SCROLL_PAGE_RATIO))
206
+ }
207
+
208
+ function ageLabel(ms) {
209
+ const mins = Math.round(ms / 60000)
210
+ if (mins < 1) return "just now"
211
+ if (mins < 60) return `${mins}m ago`
212
+ const hours = Math.round(mins / 60)
213
+ if (hours < 24) return `${hours}h ago`
214
+ return `${Math.round(hours / 24)}d ago`
215
+ }
216
+
217
+ function configuredProviders(config) {
218
+ const builtins = new Set(listProviders())
219
+ const out = []
220
+ for (const [name, value] of Object.entries(config.provider || {})) {
221
+ if (name === "default") continue
222
+ if (!value || typeof value !== "object") continue
223
+ const type = value.type || name
224
+ if (builtins.has(type)) out.push(name)
225
+ }
226
+ return out
227
+ }
228
+
229
+ async function loadHistory() {
230
+ try {
231
+ const raw = await readFile(HIST_FILE, "utf8")
232
+ return raw.split("\n").filter(Boolean).slice(-HIST_SIZE)
233
+ } catch {
234
+ return []
235
+ }
236
+ }
237
+
238
+ async function saveHistoryLines(lines) {
239
+ try {
240
+ await mkdir(HIST_DIR, { recursive: true })
241
+ const finalLines = [...lines].slice(-HIST_SIZE)
242
+ await writeFile(HIST_FILE, finalLines.join("\n") + (finalLines.length ? "\n" : ""), "utf8")
243
+ } catch {}
244
+ }
245
+
246
+ function parseConfigByPath(filePath, raw) {
247
+ if (filePath.endsWith(".json")) return JSON.parse(raw)
248
+ return YAML.parse(raw)
249
+ }
250
+
251
+ function stringifyConfigByPath(filePath, data) {
252
+ if (filePath.endsWith(".json")) return JSON.stringify(data, null, 2) + "\n"
253
+ return YAML.stringify(data)
254
+ }
255
+
256
+ function mergeObject(base, override) {
257
+ if (override === undefined || override === null) return base
258
+ if (Array.isArray(override)) return [...override]
259
+ if (!base || typeof base !== "object" || Array.isArray(base)) return override
260
+ if (typeof override !== "object") return override
261
+ const out = { ...base }
262
+ for (const key of Object.keys(override)) {
263
+ out[key] = mergeObject(base[key], override[key])
264
+ }
265
+ return out
266
+ }
267
+
268
+ function pickConfigPathForScope(scope, source, cwd = process.cwd()) {
269
+ if (scope === "user") return source?.userPath || userConfigCandidates()[0]
270
+ if (scope === "project") return source?.projectPath || projectConfigCandidates(cwd)[0]
271
+ return null
272
+ }
273
+
274
+ async function persistPermissionConfig({ scope, ctx, values }) {
275
+ const source = ctx.configState?.source || {}
276
+ const target = pickConfigPathForScope(scope, source, process.cwd())
277
+ if (!target) throw new Error(`unable to resolve ${scope} config path`)
278
+
279
+ let existing = {}
280
+ try {
281
+ const raw = await readFile(target, "utf8")
282
+ existing = parseConfigByPath(target, raw) || {}
283
+ } catch {
284
+ existing = {}
285
+ }
286
+
287
+ const merged = mergeObject(existing, {
288
+ permission: {
289
+ default_policy: values.default_policy,
290
+ non_tty_default: values.non_tty_default
291
+ }
292
+ })
293
+
294
+ await mkdir(dirname(target), { recursive: true })
295
+ await writeFile(target, stringifyConfigByPath(target, merged), "utf8")
296
+
297
+ if (scope === "user") {
298
+ ctx.configState.source.userPath = target
299
+ ctx.configState.source.userDir = dirname(target)
300
+ ctx.configState.source.userRaw = merged
301
+ } else if (scope === "project") {
302
+ ctx.configState.source.projectPath = target
303
+ ctx.configState.source.projectDir = dirname(target)
304
+ ctx.configState.source.projectRaw = merged
305
+ }
306
+
307
+ return target
308
+ }
309
+
310
+ function clearScreen() {
311
+ if (!process.stdout.isTTY) return
312
+ process.stdout.write("\x1Bc")
313
+ }
314
+
315
+ function help(providers = []) {
316
+ const rows = [
317
+ ["/help,/h,/?", "show help"],
318
+ ["/dash,/home", "show dashboard panel"],
319
+ ["/clear,/cls", "clear terminal"],
320
+ ["/new,/n", "start a new session"],
321
+ ["/resume [id],/r [id]", "resume a previous session"],
322
+ ["/history", "list recent sessions"],
323
+ ["/mode <name>,/m <name>", "switch mode (ask|plan|agent|longagent)"],
324
+ ["/provider <type>,/p <type>", `switch provider (${providers.join("|") || "configured providers"})`],
325
+ ["/model <id>", "set active model in current provider"],
326
+ ["/permission [...]", "adjust permission policy"],
327
+ ["/paste [text]", "paste clipboard image (with optional prompt)"],
328
+ ["/session,/s", "print current session id"],
329
+ ["/commands", "list custom slash commands"],
330
+ ["/create-skill <desc>", "generate a new skill via AI"],
331
+ ["/create-agent <desc>", "generate a new sub-agent via AI"],
332
+ ["/reload", "reload commands, skills, agents"],
333
+ ["/keys,/k", "show key map"],
334
+ ["/status", "show current runtime state"],
335
+ ["/exit,/quit,/q", "quit"],
336
+ ["/compact", "summarize conversation to free context"],
337
+ ["/ask /plan /agent /longagent", "quick mode switch"]
338
+ ]
339
+ const lines = ["", "Commands:"]
340
+ for (const row of rows) lines.push(` ${padRight(row[0], 28)} ${row[1]}`)
341
+
342
+ lines.push("")
343
+ lines.push("Configuration:")
344
+ lines.push(" Global config ~/.kkcode/config.yaml")
345
+ lines.push(" Project config kkcode.config.yaml / .kkcode/config.yaml")
346
+ lines.push(" Custom commands .kkcode/commands/ (project-level slash commands)")
347
+ lines.push(" Custom skills ~/.kkcode/skills/ or .kkcode/skills/")
348
+ lines.push(" Custom agents ~/.kkcode/agents/ or .kkcode/agents/")
349
+ lines.push(" Custom tools .kkcode/tools/ (project-level tool definitions)")
350
+ lines.push(" Plugins/hooks .kkcode/plugins/ (project-level hook scripts)")
351
+ lines.push(" Rules .kkcode/rules/ (project-level prompt rules)")
352
+ lines.push(" Instructions .kkcode/instructions.md or KKCODE.md")
353
+ lines.push(" MCP servers config.* -> mcp.servers")
354
+ lines.push("")
355
+ lines.push("Key config settings:")
356
+ lines.push(" provider.default default provider name")
357
+ lines.push(" provider.<name>.api_key_env env var for API key")
358
+ lines.push(" provider.<name>.default_model default model id")
359
+ lines.push(" agent.default_mode startup mode (ask|plan|agent|longagent)")
360
+ lines.push(" agent.longagent.git.enabled git branch mgmt (true|false|\"ask\")")
361
+ lines.push(" agent.longagent.usability_gates quality gates config")
362
+ lines.push(" permission.default_policy tool permission (ask|allow|deny)")
363
+ lines.push(" usage.budget.session_usd per-session cost limit")
364
+ lines.push("")
365
+ lines.push("See notice.md in project root for full configuration guide.")
366
+ return lines.join("\n")
367
+ }
368
+
369
+ function shortcutLegend() {
370
+ return [
371
+ "",
372
+ "Shortcut Map:",
373
+ " /h Help",
374
+ " /n New session",
375
+ " /r Resume latest session",
376
+ " /m Switch mode",
377
+ " /p Switch provider",
378
+ " /k Show this key map",
379
+ " /permission [show|ask|allow|deny|non-tty <allow_once|deny>|save [project|user]|session-clear]",
380
+ " /dash Redraw dashboard",
381
+ " /clear Clear screen",
382
+ " /ask /plan /agent /longagent Quick mode switch",
383
+ "",
384
+ "TUI keys:",
385
+ " Enter choose slash suggestion / submit prompt",
386
+ " Ctrl+J insert newline (Shift+Enter if terminal supports)",
387
+ " /paste paste image from clipboard (Ctrl+V if terminal supports)",
388
+ " Up/Down navigate suggestion/history",
389
+ " Left/Right/Home/End edit cursor",
390
+ " Ctrl+Up/Down scroll log Ctrl+Home/End oldest/latest",
391
+ " Tab cycle mode (longagent -> plan -> ask -> agent)",
392
+ " Esc clear input Ctrl+C exit"
393
+ ].join("\n")
394
+ }
395
+
396
+ function runtimeStateText(state) {
397
+ return [
398
+ `session=${state.sessionId}`,
399
+ `mode=${state.mode}`,
400
+ `provider=${state.providerType}`,
401
+ `model=${state.model}`
402
+ ].join("\n")
403
+ }
404
+
405
+ function normalizeFileChanges(toolEvents = []) {
406
+ const rows = []
407
+ for (const event of toolEvents || []) {
408
+ if (!event || !["write", "edit"].includes(event.name)) continue
409
+ const changes = Array.isArray(event?.metadata?.fileChanges) ? event.metadata.fileChanges : []
410
+ for (const item of changes) {
411
+ const path = String(item?.path || event.args?.path || "").trim()
412
+ if (!path) continue
413
+ rows.push({
414
+ path,
415
+ addedLines: Number(item?.addedLines || 0),
416
+ removedLines: Number(item?.removedLines || 0),
417
+ stageId: item?.stageId ? String(item.stageId) : "",
418
+ taskId: item?.taskId ? String(item.taskId) : ""
419
+ })
420
+ }
421
+ }
422
+
423
+ const grouped = new Map()
424
+ for (const row of rows) {
425
+ const key = `${row.path}::${row.stageId}::${row.taskId}`
426
+ const prev = grouped.get(key) || {
427
+ path: row.path,
428
+ addedLines: 0,
429
+ removedLines: 0,
430
+ stageId: row.stageId,
431
+ taskId: row.taskId
432
+ }
433
+ prev.addedLines += row.addedLines
434
+ prev.removedLines += row.removedLines
435
+ grouped.set(key, prev)
436
+ }
437
+ return [...grouped.values()]
438
+ }
439
+
440
+ function renderFileChangeLines(fileChanges = [], limit = 20) {
441
+ const lines = []
442
+ const rows = fileChanges.slice(0, limit)
443
+ for (const item of rows) {
444
+ const scope = [item.stageId, item.taskId].filter(Boolean).join("/")
445
+ const suffix = scope ? paint(` (${scope})`, null, { dim: true }) : ""
446
+ // 使用亮色和加粗让变更更醒目
447
+ const add = item.addedLines > 0
448
+ ? paint(`+${item.addedLines}`, "#00ff00", { bold: true })
449
+ : paint("+0", null, { dim: true })
450
+ const del = item.removedLines > 0
451
+ ? paint(`-${item.removedLines}`, "#ff4444", { bold: true })
452
+ : paint("-0", null, { dim: true })
453
+ lines.push(` ${paint(item.path, "white")} ${add} ${del}${suffix}`)
454
+ }
455
+ if (fileChanges.length > rows.length) {
456
+ lines.push(paint(` ... +${fileChanges.length - rows.length} more file(s)`, null, { dim: true }))
457
+ }
458
+ return lines
459
+ }
460
+
461
+ function resolveProviderDefaultModel(config, providerType, fallback = "") {
462
+ return (
463
+ config.provider?.[providerType]?.default_model ||
464
+ config.provider?.[config.provider?.default]?.default_model ||
465
+ fallback
466
+ )
467
+ }
468
+
469
+ function buildSlashCatalog(customCommands = []) {
470
+ const custom = customCommands.map((cmd) => ({
471
+ name: cmd.name,
472
+ desc: `custom (${cmd.scope || "project"})`
473
+ }))
474
+ const skills = SkillRegistry.isReady()
475
+ ? SkillRegistry.list()
476
+ .filter((s) => !custom.some((c) => c.name === s.name))
477
+ .map((s) => ({ name: s.name, desc: `skill (${s.type})` }))
478
+ : []
479
+ return [...BUILTIN_SLASH, ...custom, ...skills]
480
+ }
481
+
482
+ function slashQuery(inputLine) {
483
+ if (!String(inputLine || "").startsWith("/")) return null
484
+ const raw = String(inputLine).slice(1)
485
+ const firstSpace = raw.indexOf(" ")
486
+ const token = (firstSpace >= 0 ? raw.slice(0, firstSpace) : raw).trim()
487
+ return token
488
+ }
489
+
490
+ function slashSuggestions(inputLine, customCommands) {
491
+ const token = slashQuery(inputLine)
492
+ if (token === null) return []
493
+ const all = buildSlashCatalog(customCommands)
494
+ const q = token.toLowerCase()
495
+ const ranked = all
496
+ .map((item) => {
497
+ const name = item.name.toLowerCase()
498
+ let rank = 99
499
+ if (!q) rank = 0
500
+ else if (name === q) rank = 0
501
+ else if (name.startsWith(q)) rank = 1
502
+ else if (name.includes(q)) rank = 2
503
+ return { ...item, rank }
504
+ })
505
+ .filter((item) => item.rank < 99)
506
+ .sort((a, b) => (a.rank - b.rank) || a.name.localeCompare(b.name))
507
+
508
+ return ranked
509
+ }
510
+
511
+ function applySuggestionToInput(current, suggestionName) {
512
+ const raw = String(current || "")
513
+ if (!raw.startsWith("/")) return raw
514
+ const body = raw.slice(1)
515
+ const firstSpace = body.indexOf(" ")
516
+ if (firstSpace < 0) return `/${suggestionName} `
517
+ return `/${suggestionName}${body.slice(firstSpace)}`
518
+ }
519
+
520
+ function cycleMode(state) {
521
+ const idx = MODE_CYCLE_ORDER.indexOf(state.mode)
522
+ const nextIdx = idx >= 0 ? (idx + 1) % MODE_CYCLE_ORDER.length : 0
523
+ state.mode = MODE_CYCLE_ORDER[nextIdx]
524
+ return state.mode
525
+ }
526
+
527
+ /**
528
+ * Collect single-line or multi-line input from the user.
529
+ * - `"""` block mode: starts with `"""`, collects until a line is exactly `"""`
530
+ * - `\` continuation: line ending with `\` continues on next line
531
+ * - Otherwise: single line
532
+ */
533
+ export async function collectInput(rl, promptStr) {
534
+ const first = (await rl.question(promptStr)).trim()
535
+ if (!first) return ""
536
+
537
+ if (first === '"""' || first.startsWith('"""')) {
538
+ const lines = []
539
+ if (first !== '"""') lines.push(first.slice(3))
540
+ while (true) {
541
+ const next = await rl.question("... ")
542
+ if (next.trim() === '"""') break
543
+ lines.push(next)
544
+ }
545
+ return lines.join("\n").trim()
546
+ }
547
+
548
+ if (first.endsWith("\\")) {
549
+ const lines = [first.slice(0, -1)]
550
+ while (true) {
551
+ const next = await rl.question("... ")
552
+ if (next.endsWith("\\")) lines.push(next.slice(0, -1))
553
+ else {
554
+ lines.push(next)
555
+ break
556
+ }
557
+ }
558
+ return lines.join("\n").trim()
559
+ }
560
+
561
+ return first
562
+ }
563
+
564
+ async function executePromptTurn({ prompt, state, ctx, streamSink = null, pendingImages = [] }) {
565
+ // Detect image file references in the prompt
566
+ const { text: cleanedPrompt, imagePaths, imageUrls = [] } = extractImageRefs(prompt, process.cwd())
567
+ const effectivePrompt = cleanedPrompt ?? prompt
568
+ let contentBlocks = null
569
+ if (imagePaths.length || imageUrls.length || pendingImages.length) {
570
+ contentBlocks = await buildContentBlocks(effectivePrompt, imagePaths, imageUrls)
571
+ // buildContentBlocks returns plain string when no file images — normalize to array
572
+ if (typeof contentBlocks === "string") {
573
+ contentBlocks = [{ type: "text", text: contentBlocks }]
574
+ }
575
+ for (const img of pendingImages) {
576
+ if (img && img.type === "image") contentBlocks.push(img)
577
+ }
578
+ }
579
+
580
+ const chatParams = await HookBus.chatParams({
581
+ prompt: effectivePrompt,
582
+ mode: state.mode,
583
+ model: state.model,
584
+ providerType: state.providerType,
585
+ sessionId: state.sessionId
586
+ })
587
+
588
+ const exec = async () => executeTurn({
589
+ prompt: chatParams.prompt ?? effectivePrompt,
590
+ contentBlocks,
591
+ mode: chatParams.mode ?? state.mode,
592
+ model: chatParams.model ?? state.model,
593
+ sessionId: state.sessionId,
594
+ configState: ctx.configState,
595
+ providerType: chatParams.providerType ?? state.providerType,
596
+ output: streamSink && typeof streamSink === "function"
597
+ ? { write: streamSink }
598
+ : null
599
+ })
600
+ return { result: await exec() }
601
+ }
602
+
603
+ function normalizeSlashAlias(line) {
604
+ if (line === "/h") return "/help"
605
+ if (line === "/?") return "/help"
606
+ if (line === "/n") return "/new"
607
+ if (line === "/s") return "/session"
608
+ if (line === "/k") return "/keys"
609
+ if (line === "/r") return "/resume"
610
+ if (line === "/m") return "/mode"
611
+ if (line === "/p") return "/provider"
612
+ if (line === "/q") return "/exit"
613
+ return line
614
+ }
615
+
616
+ async function processInputLine({
617
+ line,
618
+ state,
619
+ ctx,
620
+ providersConfigured,
621
+ customCommands,
622
+ setCustomCommands,
623
+ print,
624
+ streamSink = null,
625
+ showTurnStatus = true,
626
+ pendingImages = [],
627
+ clearPendingImages = null
628
+ }) {
629
+ const normalized = normalizeSlashAlias(String(line || "").trim())
630
+
631
+ if (!normalized) return { exit: false }
632
+ if (normalized === "/") return { exit: false }
633
+ if (["/exit", "/quit", "/q"].includes(normalized)) return { exit: true }
634
+
635
+ if (["/help", "/h", "/?"].includes(normalized)) {
636
+ print(help(providersConfigured))
637
+ return { exit: false }
638
+ }
639
+
640
+ if (["/keys", "/k"].includes(normalized)) {
641
+ print(shortcutLegend())
642
+ return { exit: false }
643
+ }
644
+
645
+ if (["/session", "/s"].includes(normalized)) {
646
+ print(`session=${state.sessionId}`)
647
+ return { exit: false }
648
+ }
649
+
650
+ if (["/status"].includes(normalized)) {
651
+ const latest = await listSessions({ cwd: process.cwd(), limit: 6, includeChildren: false }).catch(() => [])
652
+ print(
653
+ renderReplDashboard({
654
+ theme: ctx.themeState.theme,
655
+ state,
656
+ providers: providersConfigured,
657
+ recentSessions: latest,
658
+ customCommandCount: customCommands.length,
659
+ cwd: process.cwd()
660
+ })
661
+ )
662
+ print("")
663
+ print(runtimeStateText(state))
664
+ return { exit: false }
665
+ }
666
+
667
+ if (["/clear", "/cls"].includes(normalized)) {
668
+ return { exit: false, cleared: true }
669
+ }
670
+
671
+ if (["/dash", "/dashboard", "/home"].includes(normalized)) {
672
+ const recent = await listSessions({ cwd: process.cwd(), limit: 6, includeChildren: false }).catch(() => [])
673
+ return { exit: false, dashboardRefresh: true, recentSessions: recent }
674
+ }
675
+
676
+ if (["/commands"].includes(normalized)) {
677
+ const skills = SkillRegistry.isReady() ? SkillRegistry.list() : []
678
+ if (!customCommands.length && !skills.length) print("no custom commands or skills found")
679
+ else {
680
+ if (customCommands.length) {
681
+ print("custom commands:")
682
+ customCommands.forEach((cmd) => print(` /${cmd.name} (${cmd.scope}) -> ${cmd.source}`))
683
+ }
684
+ const nonCustomSkills = skills.filter((s) => s.type !== "template")
685
+ if (nonCustomSkills.length) {
686
+ print("skills:")
687
+ nonCustomSkills.forEach((s) => print(` /${s.name} (${s.type}${s.scope ? ", " + s.scope : ""})`))
688
+ }
689
+ }
690
+ return { exit: false }
691
+ }
692
+
693
+ if (["/reload"].includes(normalized)) {
694
+ const reloaded = await loadCustomCommands(process.cwd())
695
+ setCustomCommands(reloaded)
696
+ await SkillRegistry.initialize(ctx.configState.config, process.cwd())
697
+ const { CustomAgentRegistry } = await import("./agent/custom-agent-loader.mjs")
698
+ await CustomAgentRegistry.initialize(process.cwd())
699
+ const skillCount = SkillRegistry.isReady() ? SkillRegistry.list().length : 0
700
+ const agentCount = CustomAgentRegistry.list().length
701
+ print(`reloaded commands: ${reloaded.length}, skills: ${skillCount}, agents: ${agentCount}`)
702
+ return { exit: false }
703
+ }
704
+
705
+ if (["/trust"].includes(normalized)) {
706
+ await persistTrust(process.cwd())
707
+ PermissionEngine.setTrusted(true)
708
+ print("workspace trusted")
709
+ return { exit: false }
710
+ }
711
+ if (["/untrust"].includes(normalized)) {
712
+ await revokeTrust(process.cwd())
713
+ PermissionEngine.setTrusted(false)
714
+ print("workspace trust revoked — tools are now blocked")
715
+ return { exit: false }
716
+ }
717
+
718
+ if (["/compact"].includes(normalized)) {
719
+ try {
720
+ print("compacting conversation...")
721
+ const result = await compactSession({
722
+ sessionId: state.sessionId,
723
+ model: state.model,
724
+ providerType: state.providerType,
725
+ configState: ctx.configState
726
+ })
727
+ if (result.compacted) {
728
+ print(`compacted: ${result.summarizedCount} messages summarized, ${result.keptCount} kept`)
729
+ } else {
730
+ print(`skipped: ${result.reason}`)
731
+ }
732
+ } catch (err) {
733
+ print(`compact failed: ${err.message}`)
734
+ }
735
+ return { exit: false }
736
+ }
737
+
738
+ if (["/new", "/n"].includes(normalized)) {
739
+ state.sessionId = newSessionId()
740
+ print(`new session: ${state.sessionId}`)
741
+ return { exit: false }
742
+ }
743
+
744
+ if (["/history"].includes(normalized)) {
745
+ const sessions = await listSessions({ cwd: process.cwd(), limit: 8, includeChildren: false })
746
+ if (!sessions.length) print("no sessions found")
747
+ else {
748
+ for (const s of sessions) {
749
+ const age = ageLabel(Date.now() - s.updatedAt)
750
+ print(` ${s.id.slice(0, 12)} ${padRight(s.mode, 9)} ${padRight(s.model || "?", 20)} ${padRight(s.status || "-", 14)} ${age}`)
751
+ }
752
+ }
753
+ return { exit: false }
754
+ }
755
+
756
+ if (normalized === "/resume" || normalized.startsWith("/resume ") || normalized === "/r" || normalized.startsWith("/r ")) {
757
+ const arg = normalized.replace(/^\/(resume|r)/, "").trim()
758
+ const sessions = await listSessions({ cwd: process.cwd(), limit: 20, includeChildren: false })
759
+ let target = null
760
+ if (!arg) target = sessions[0] || null
761
+ else target = sessions.find((s) => s.id === arg || s.id.startsWith(arg)) || null
762
+
763
+ if (!target) {
764
+ print(arg ? `no session matching "${arg}"` : "no sessions to resume")
765
+ return { exit: false }
766
+ }
767
+
768
+ state.sessionId = target.id
769
+ state.mode = target.mode || state.mode
770
+ state.providerType = target.providerType || state.providerType
771
+ state.model = target.model || state.model
772
+ print(`resumed session: ${target.id} (${target.mode}, ${target.model || "?"})`)
773
+ const msgs = await getConversationHistory(target.id, 3)
774
+ for (const m of msgs) {
775
+ const preview = m.content.length > 84 ? `${m.content.slice(0, 84)}...` : m.content
776
+ print(` [${m.role}] ${preview}`)
777
+ }
778
+ return { exit: false }
779
+ }
780
+
781
+ if (["/ask", "/plan", "/agent", "/longagent"].includes(normalized)) {
782
+ state.mode = resolveMode(normalized.slice(1))
783
+ print(`mode switched: ${state.mode}`)
784
+ return { exit: false }
785
+ }
786
+
787
+ if (normalized.startsWith("/mode ") || normalized.startsWith("/m ")) {
788
+ const next = resolveMode(normalized.replace(/^\/(mode|m)\s+/, "").trim())
789
+ state.mode = next
790
+ print(`mode switched: ${next}`)
791
+ return { exit: false }
792
+ }
793
+
794
+ if (normalized === "/provider" || normalized === "/p") {
795
+ print(`available providers: ${providersConfigured.join(", ")}`)
796
+ return { exit: false }
797
+ }
798
+
799
+ if (normalized.startsWith("/provider ") || normalized.startsWith("/p ")) {
800
+ const next = normalized.replace(/^\/(provider|p)\s+/, "").trim()
801
+ if (!providersConfigured.includes(next)) {
802
+ print(`provider must be one of: ${providersConfigured.join(", ")}`)
803
+ return { exit: false }
804
+ }
805
+ state.providerType = next
806
+ state.model = resolveProviderDefaultModel(ctx.configState.config, next, state.model)
807
+ print(`provider switched: ${next}`)
808
+ return { exit: false }
809
+ }
810
+
811
+ if (normalized === "/model") {
812
+ print(`current: ${state.providerType} / ${state.model}`)
813
+ return { exit: false, openModelPicker: true }
814
+ }
815
+
816
+ if (normalized.startsWith("/model ")) {
817
+ const next = normalized.replace("/model ", "").trim()
818
+ if (!next) print("usage: /model <model-id>")
819
+ else {
820
+ state.model = next
821
+ print(`model switched: ${next}`)
822
+ }
823
+ return { exit: false }
824
+ }
825
+
826
+ if (normalized === "/permission" || normalized.startsWith("/permission ")) {
827
+ const tokens = normalized.split(/\s+/).slice(1)
828
+ const sub = (tokens[0] || "show").toLowerCase()
829
+ const permission = ctx.configState.config.permission || (ctx.configState.config.permission = {})
830
+
831
+ if (sub === "show") {
832
+ print(`current: ${permission.default_policy || "ask"}`)
833
+ return { exit: false, openPolicyPicker: true }
834
+ }
835
+
836
+ if (["ask", "allow", "deny"].includes(sub)) {
837
+ permission.default_policy = sub
838
+ print(`permission.default_policy -> ${sub} (runtime)`)
839
+ return { exit: false }
840
+ }
841
+
842
+ if (sub === "non-tty") {
843
+ const value = String(tokens[1] || "").toLowerCase()
844
+ if (!["allow_once", "deny"].includes(value)) {
845
+ print("usage: /permission non-tty <allow_once|deny>")
846
+ return { exit: false }
847
+ }
848
+ permission.non_tty_default = value
849
+ print(`permission.non_tty_default -> ${value} (runtime)`)
850
+ return { exit: false }
851
+ }
852
+
853
+ if (sub === "save") {
854
+ const scope = String(tokens[1] || "project").toLowerCase()
855
+ if (!["project", "user"].includes(scope)) {
856
+ print("usage: /permission save [project|user]")
857
+ return { exit: false }
858
+ }
859
+ try {
860
+ const target = await persistPermissionConfig({
861
+ scope,
862
+ ctx,
863
+ values: {
864
+ default_policy: permission.default_policy || "ask",
865
+ non_tty_default: permission.non_tty_default || "deny"
866
+ }
867
+ })
868
+ print(`permission saved (${scope}) -> ${target}`)
869
+ } catch (error) {
870
+ print(`permission save failed: ${error.message}`)
871
+ }
872
+ return { exit: false }
873
+ }
874
+
875
+ if (sub === "session-clear" || sub === "reset") {
876
+ PermissionEngine.clearSession(state.sessionId)
877
+ print(`permission session cache cleared: ${state.sessionId}`)
878
+ return { exit: false }
879
+ }
880
+
881
+ print("usage: /permission [show|ask|allow|deny|non-tty <allow_once|deny>|save [project|user]|session-clear]")
882
+ return { exit: false }
883
+ }
884
+
885
+ // /paste — read clipboard image, optionally with prompt text
886
+ if (normalized === "/paste" || normalized.startsWith("/paste ")) {
887
+ const pasteText = normalized.replace(/^\/paste\s*/, "").trim()
888
+ print("reading clipboard...")
889
+ const clipBlock = await readClipboardImage({ onStatus: (msg) => { if (msg) print(msg) } })
890
+ if (!clipBlock || clipBlock.type === "error") {
891
+ print(clipBlock?.message ? `paste failed: ${clipBlock.message}` : "no image found in clipboard")
892
+ return { exit: false }
893
+ }
894
+ if (!pasteText) {
895
+ // Just attach — store for next message
896
+ pendingImages.push(clipBlock)
897
+ print(`image pasted from clipboard (${pendingImages.length} image(s) attached, send a message to include)`)
898
+ return { exit: false, pastedImage: true }
899
+ }
900
+ // Has text — send immediately with the image
901
+ const allImages = [...pendingImages, clipBlock]
902
+ if (clearPendingImages) clearPendingImages()
903
+ const turn = await executePromptTurn({
904
+ prompt: pasteText,
905
+ state,
906
+ ctx,
907
+ streamSink: state.mode === "longagent" ? null : streamSink,
908
+ pendingImages: allImages
909
+ })
910
+ const result = turn.result
911
+ const status = renderStatusBar({
912
+ mode: state.mode, model: state.model,
913
+ permission: ctx.configState.config.permission.default_policy,
914
+ tokenMeter: result.tokenMeter, aggregation: ctx.configState.config.usage.aggregation,
915
+ cost: result.cost, savings: result.costSavings, contextMeter: result.context,
916
+ showCost: ctx.configState.config.ui.status.show_cost,
917
+ showTokenMeter: ctx.configState.config.ui.status.show_token_meter,
918
+ theme: ctx.themeState.theme, layout: ctx.configState.config.ui.layout,
919
+ longagentState: state.mode === "longagent" ? result.longagent : null,
920
+ memoryLoaded: state.memoryLoaded
921
+ })
922
+ if (showTurnStatus) print(status)
923
+ if (!result.emittedText) {
924
+ const mdEnabled = ctx.configState.config.ui?.markdown_render !== false
925
+ print(mdEnabled ? renderMarkdown(result.reply) : result.reply)
926
+ }
927
+ return { exit: false, turnResult: { tokenMeter: result.tokenMeter, cost: result.cost, costSavings: result.costSavings, context: result.context, longagent: result.longagent, toolEvents: result.toolEvents } }
928
+ }
929
+
930
+ // /create-skill — AI generates a new skill from description
931
+ if (normalized === "/create-skill" || normalized.startsWith("/create-skill ")) {
932
+ const description = normalized.replace(/^\/create-skill\s*/, "").trim()
933
+ if (!description) {
934
+ print("usage: /create-skill <description of what the skill should do>")
935
+ print("example: /create-skill review code for security vulnerabilities")
936
+ return { exit: false }
937
+ }
938
+ print(`generating skill: ${description}`)
939
+ try {
940
+ const skill = await generateSkill({
941
+ description,
942
+ configState: ctx.configState,
943
+ providerType: state.providerType,
944
+ model: state.model,
945
+ baseUrl: null,
946
+ apiKeyEnv: null
947
+ })
948
+ if (!skill) {
949
+ print("skill generation failed — no output from model")
950
+ return { exit: false }
951
+ }
952
+ print(`--- ${skill.filename} ---`)
953
+ print(skill.content)
954
+ print("---")
955
+ const savedPath = await saveSkillGlobal(skill.filename, skill.content)
956
+ print(`saved to: ${savedPath}`)
957
+ // Reload skills
958
+ await SkillRegistry.initialize(ctx.configState.config, process.cwd())
959
+ print(`skill /${skill.name} is now available`)
960
+ } catch (error) {
961
+ print(`skill generation error: ${error.message}`)
962
+ }
963
+ return { exit: false }
964
+ }
965
+
966
+ // /create-agent — AI generates a new sub-agent from description
967
+ if (normalized === "/create-agent" || normalized.startsWith("/create-agent ")) {
968
+ const description = normalized.replace(/^\/create-agent\s*/, "").trim()
969
+ if (!description) {
970
+ print("usage: /create-agent <description of what the agent should do>")
971
+ print("example: /create-agent code reviewer that focuses on security vulnerabilities")
972
+ return { exit: false }
973
+ }
974
+ print(`generating agent: ${description}`)
975
+ try {
976
+ const { generateAgent, saveAgentGlobal } = await import("./agent/generator.mjs")
977
+ const agent = await generateAgent({
978
+ description,
979
+ configState: ctx.configState,
980
+ providerType: state.providerType,
981
+ model: state.model,
982
+ baseUrl: null,
983
+ apiKeyEnv: null
984
+ })
985
+ if (!agent) {
986
+ print("agent generation failed — no output from model")
987
+ return { exit: false }
988
+ }
989
+ print(`--- ${agent.filename} ---`)
990
+ print(agent.content)
991
+ print("---")
992
+ const savedPath = await saveAgentGlobal(agent.filename, agent.content)
993
+ print(`saved to: ${savedPath}`)
994
+ // Reload custom agents
995
+ const { CustomAgentRegistry } = await import("./agent/custom-agent-loader.mjs")
996
+ await CustomAgentRegistry.initialize(process.cwd())
997
+ print(`agent "${agent.name}" is now available as a sub-agent`)
998
+ } catch (error) {
999
+ print(`agent generation error: ${error.message}`)
1000
+ }
1001
+ return { exit: false }
1002
+ }
1003
+
1004
+ let prompt = normalized
1005
+ if (normalized.startsWith("/")) {
1006
+ const body = normalized.slice(1)
1007
+ const [name, ...argTokens] = body.split(/\s+/)
1008
+ const args = argTokens.join(" ").trim()
1009
+
1010
+ // Try SkillRegistry first (covers templates, .mjs skills, MCP prompts)
1011
+ const skill = SkillRegistry.isReady() ? SkillRegistry.get(name) : null
1012
+ if (skill) {
1013
+ const expanded = await SkillRegistry.execute(name, args, {
1014
+ cwd: process.cwd(),
1015
+ mode: state.mode,
1016
+ model: state.model,
1017
+ provider: state.providerType
1018
+ })
1019
+ if (!expanded) {
1020
+ print(`skill /${name} returned no output`)
1021
+ return { exit: false }
1022
+ }
1023
+ prompt = expanded
1024
+ } else {
1025
+ // Fallback: check raw custom commands (in case SkillRegistry not ready)
1026
+ const custom = customCommands.find((item) => item.name === name)
1027
+ if (!custom) {
1028
+ print(`unknown slash command: /${name}`)
1029
+ return { exit: false }
1030
+ }
1031
+ prompt = applyCommandTemplate(custom.template, args, {
1032
+ path: process.cwd(),
1033
+ mode: state.mode,
1034
+ provider: state.providerType,
1035
+ cwd: process.cwd(),
1036
+ project: basename(process.cwd())
1037
+ })
1038
+ }
1039
+ }
1040
+
1041
+ // Include any pending clipboard images with this message
1042
+ const images = pendingImages.length ? [...pendingImages] : []
1043
+ if (clearPendingImages && images.length) clearPendingImages()
1044
+
1045
+ const turn = await executePromptTurn({
1046
+ prompt,
1047
+ state,
1048
+ ctx,
1049
+ streamSink: state.mode === "longagent" ? null : streamSink,
1050
+ pendingImages: images
1051
+ })
1052
+ const result = turn.result
1053
+
1054
+ const status = renderStatusBar({
1055
+ mode: state.mode,
1056
+ model: state.model,
1057
+ permission: ctx.configState.config.permission.default_policy,
1058
+ tokenMeter: result.tokenMeter,
1059
+ aggregation: ctx.configState.config.usage.aggregation,
1060
+ cost: result.cost,
1061
+ savings: result.costSavings,
1062
+ contextMeter: result.context,
1063
+ showCost: ctx.configState.config.ui.status.show_cost,
1064
+ showTokenMeter: ctx.configState.config.ui.status.show_token_meter,
1065
+ theme: ctx.themeState.theme,
1066
+ layout: ctx.configState.config.ui.layout,
1067
+ longagentState: state.mode === "longagent" ? result.longagent : null,
1068
+ memoryLoaded: state.memoryLoaded
1069
+ })
1070
+ if (showTurnStatus) print(status)
1071
+
1072
+ const toolFileChanges = normalizeFileChanges(result.toolEvents)
1073
+ const longagentFileChanges = normalizeFileChanges(
1074
+ Array.isArray(result.longagent?.fileChanges)
1075
+ ? result.longagent.fileChanges.map((item) => ({
1076
+ name: "write",
1077
+ metadata: { fileChanges: [item] }
1078
+ }))
1079
+ : []
1080
+ )
1081
+ const fileChanges = state.mode === "longagent" && longagentFileChanges.length
1082
+ ? longagentFileChanges
1083
+ : toolFileChanges
1084
+
1085
+ if (state.mode === "longagent") {
1086
+ if (result.longagent) {
1087
+ const stg = result.longagent.currentStageId
1088
+ ? result.longagent.currentStageId
1089
+ : `${(result.longagent.stageIndex || 0) + 1}/${Math.max(1, result.longagent.stageCount || 1)}`
1090
+ print(`longagent: phase=${result.longagent.phase || "-"} stage=${stg} gate=${result.longagent.currentGate || "-"}`)
1091
+ if (result.longagent.taskProgress && Object.keys(result.longagent.taskProgress).length) {
1092
+ for (const line of formatPlanProgress(result.longagent.taskProgress)) print(line)
1093
+ }
1094
+ }
1095
+ if (fileChanges.length) {
1096
+ print(paint("changed files:", "cyan", { bold: true }))
1097
+ for (const line of renderFileChangeLines(fileChanges)) print(line)
1098
+ } else if (!result.emittedText && result.reply) {
1099
+ const mdEnabled = ctx.configState.config.ui?.markdown_render !== false
1100
+ print(mdEnabled ? renderMarkdown(result.reply) : result.reply)
1101
+ }
1102
+ } else {
1103
+ if (!result.emittedText) {
1104
+ const mdEnabled = ctx.configState.config.ui?.markdown_render !== false
1105
+ print(mdEnabled ? renderMarkdown(result.reply) : result.reply)
1106
+ }
1107
+ if (fileChanges.length) {
1108
+ print(paint("changed files:", "cyan", { bold: true }))
1109
+ for (const line of renderFileChangeLines(fileChanges, 10)) print(line)
1110
+ }
1111
+ }
1112
+ return {
1113
+ exit: false,
1114
+ turnResult: {
1115
+ tokenMeter: result.tokenMeter,
1116
+ cost: result.cost,
1117
+ context: result.context,
1118
+ longagent: result.longagent,
1119
+ toolEvents: result.toolEvents
1120
+ }
1121
+ }
1122
+ }
1123
+
1124
+ async function startLineRepl({ ctx, state, providersConfigured, customCommands, recentSessions, historyLines }) {
1125
+ const rl = createInterface({ input, output, history: historyLines, historySize: HIST_SIZE })
1126
+ let localCustomCommands = customCommands
1127
+ const entered = [...historyLines]
1128
+ const lastTurn = {
1129
+ tokenMeter: {
1130
+ estimated: false,
1131
+ turn: { input: 0, output: 0 },
1132
+ session: { input: 0, output: 0 },
1133
+ global: { input: 0, output: 0 }
1134
+ },
1135
+ cost: 0,
1136
+ context: null,
1137
+ longagent: null
1138
+ }
1139
+
1140
+ console.log(
1141
+ renderReplLogo({
1142
+ theme: ctx.themeState.theme,
1143
+ columns: Number(process.stdout.columns || 120)
1144
+ })
1145
+ )
1146
+ const hint = renderStartupHint(recentSessions)
1147
+ if (hint) console.log(`${hint}\n`)
1148
+
1149
+ const lineActivityRenderer = createActivityRenderer({
1150
+ theme: ctx.themeState.theme,
1151
+ output: {
1152
+ appendLog: (text) => console.log(text),
1153
+ appendStreamChunk: (chunk) => process.stdout.write(chunk)
1154
+ }
1155
+ })
1156
+ lineActivityRenderer.start()
1157
+
1158
+ let linePendingImages = []
1159
+
1160
+ while (true) {
1161
+ const status = renderStatusBar({
1162
+ mode: state.mode,
1163
+ model: state.model,
1164
+ permission: ctx.configState.config.permission.default_policy,
1165
+ tokenMeter: lastTurn.tokenMeter,
1166
+ aggregation: ctx.configState.config.usage.aggregation,
1167
+ cost: lastTurn.cost,
1168
+ savings: lastTurn.costSavings,
1169
+ contextMeter: lastTurn.context,
1170
+ showCost: ctx.configState.config.ui.status.show_cost,
1171
+ showTokenMeter: ctx.configState.config.ui.status.show_token_meter,
1172
+ theme: ctx.themeState.theme,
1173
+ layout: ctx.configState.config.ui.layout,
1174
+ longagentState: state.mode === "longagent" ? lastTurn.longagent : null,
1175
+ memoryLoaded: state.memoryLoaded
1176
+ })
1177
+
1178
+ const line = await collectInput(rl, `${status}\n> `)
1179
+ if (!line) continue
1180
+ entered.push(line)
1181
+
1182
+ const action = await processInputLine({
1183
+ line,
1184
+ state,
1185
+ ctx,
1186
+ providersConfigured,
1187
+ customCommands: localCustomCommands,
1188
+ setCustomCommands: (next) => {
1189
+ localCustomCommands = next
1190
+ },
1191
+ print: (text) => console.log(text),
1192
+ pendingImages: linePendingImages,
1193
+ clearPendingImages: () => { linePendingImages = [] }
1194
+ })
1195
+
1196
+ if (action.cleared) clearScreen()
1197
+ if (action.dashboardRefresh) {
1198
+ const latest = action.recentSessions || []
1199
+ console.log(
1200
+ renderReplDashboard({
1201
+ theme: ctx.themeState.theme,
1202
+ state,
1203
+ providers: providersConfigured,
1204
+ recentSessions: latest,
1205
+ customCommandCount: localCustomCommands.length,
1206
+ cwd: process.cwd()
1207
+ })
1208
+ )
1209
+ }
1210
+
1211
+ if (action.turnResult) {
1212
+ lastTurn.tokenMeter = action.turnResult.tokenMeter || lastTurn.tokenMeter
1213
+ lastTurn.cost = Number.isFinite(action.turnResult.cost) ? action.turnResult.cost : lastTurn.cost
1214
+ lastTurn.context = action.turnResult.context || null
1215
+ lastTurn.longagent = action.turnResult.longagent || null
1216
+ }
1217
+
1218
+ if (action.exit) break
1219
+ }
1220
+
1221
+ lineActivityRenderer.stop()
1222
+ rl.close()
1223
+ await saveHistoryLines(entered)
1224
+ }
1225
+
1226
+ function startTuiFrame() {
1227
+ output.write("\x1b[?1049h")
1228
+ output.write("\x1b[?25l")
1229
+ }
1230
+
1231
+ function stopTuiFrame() {
1232
+ output.write("\x1b[?25h")
1233
+ output.write("\x1b[?1049l")
1234
+ }
1235
+
1236
+ function hasShiftEnterSequence(dataChunk) {
1237
+ const text = Buffer.isBuffer(dataChunk) ? dataChunk.toString("utf8") : String(dataChunk || "")
1238
+ if (!text || text.length < 2) return false
1239
+ return (
1240
+ text.includes("\x1b[13;2u") ||
1241
+ text.includes("\x1b[27;2;13~") ||
1242
+ text.includes("\x1b[13;2~")
1243
+ )
1244
+ }
1245
+
1246
+ function renderSuggestions({ inputLine, suggestions, selected, offset, maxVisible, theme, width }) {
1247
+ if (!String(inputLine || "").startsWith("/") || !suggestions.length) {
1248
+ return { lines: [], offset: 0 }
1249
+ }
1250
+ const visible = Math.max(1, maxVisible || MAX_TUI_SUGGESTIONS)
1251
+ let start = Math.max(0, Math.min(offset || 0, Math.max(0, suggestions.length - visible)))
1252
+ if (selected < start) start = selected
1253
+ if (selected >= start + visible) start = selected - visible + 1
1254
+
1255
+ const end = Math.min(suggestions.length, start + visible)
1256
+ const view = suggestions.slice(start, end)
1257
+ const lines = [
1258
+ paint(
1259
+ `Slash Commands (${selected + 1}/${suggestions.length}) Enter choose, Enter again execute`,
1260
+ theme.base.muted,
1261
+ { bold: true }
1262
+ )
1263
+ ]
1264
+ for (let i = 0; i < view.length; i++) {
1265
+ const item = view[i]
1266
+ const index = start + i
1267
+ const active = index === selected
1268
+ const prefix = active ? ">" : " "
1269
+ const line = `${prefix} /${padRight(item.name, 14)} ${item.desc}`
1270
+ lines.push(
1271
+ active
1272
+ ? paint(line, "#111111", { bg: theme.semantic.info, bold: true })
1273
+ : paint(line, theme.base.fg)
1274
+ )
1275
+ }
1276
+ if (suggestions.length > visible) {
1277
+ lines.push(
1278
+ paint(`scroll: ${start + 1}-${end}/${suggestions.length} (Up/Down)`, theme.base.muted)
1279
+ )
1280
+ }
1281
+ return {
1282
+ lines: lines.map((line) => clipAnsiLine(line, width)),
1283
+ offset: start
1284
+ }
1285
+ }
1286
+
1287
+ async function startTuiRepl({ ctx, state, providersConfigured, customCommands, recentSessions, historyLines, mcpStatusLines = [] }) {
1288
+ let localCustomCommands = customCommands
1289
+ let localRecentSessions = recentSessions
1290
+
1291
+ const ui = {
1292
+ input: "",
1293
+ inputCursor: 0,
1294
+ logs: [...mcpStatusLines],
1295
+ busy: false,
1296
+ pendingImages: [],
1297
+ permissionQueue: [],
1298
+ pendingPermission: null,
1299
+ permissionSelected: 0,
1300
+ questionQueue: [],
1301
+ pendingQuestion: null,
1302
+ questionIndex: 0,
1303
+ questionOptionSelected: 0,
1304
+ questionMultiSelected: {},
1305
+ questionCustomMode: false,
1306
+ questionCustomInput: "",
1307
+ questionCustomCursor: 0,
1308
+ questionAnswers: {},
1309
+ modelPicker: null,
1310
+ policyPicker: null,
1311
+ selectedSuggestion: 0,
1312
+ suggestionOffset: 0,
1313
+ history: [...historyLines],
1314
+ historyIndex: historyLines.length,
1315
+ scrollOffset: 0,
1316
+ quitting: false,
1317
+ showDashboard: true,
1318
+ scrollMeta: {
1319
+ logRows: 0,
1320
+ totalRows: 0,
1321
+ maxOffset: 0
1322
+ },
1323
+ spinnerIndex: 0,
1324
+ currentActivity: null,
1325
+ currentStep: 0,
1326
+ maxSteps: 0,
1327
+ metrics: {
1328
+ tokenMeter: {
1329
+ estimated: false,
1330
+ turn: { input: 0, output: 0 },
1331
+ session: { input: 0, output: 0 },
1332
+ global: { input: 0, output: 0 }
1333
+ },
1334
+ cost: 0,
1335
+ context: null,
1336
+ longagent: null,
1337
+ toolEvents: []
1338
+ }
1339
+ }
1340
+ let lastFrame = []
1341
+ let lastFrameWidth = 0
1342
+ let forceFullPaint = true
1343
+ let renderScheduled = false
1344
+ let renderTimer = null
1345
+ let spinnerTimer = null
1346
+
1347
+ function appendLog(text = "") {
1348
+ const follow = ui.scrollOffset === 0
1349
+ const lines = String(text || "").replace(/\r/g, "").split("\n")
1350
+ for (const line of lines) ui.logs.push(line)
1351
+ if (ui.logs.length > MAX_TUI_LOG_LINES) ui.logs.splice(0, ui.logs.length - MAX_TUI_LOG_LINES)
1352
+ if (follow) ui.scrollOffset = 0
1353
+ }
1354
+
1355
+ function appendStreamChunk(chunk = "") {
1356
+ const follow = ui.scrollOffset === 0
1357
+ const text = String(chunk || "").replace(/\r/g, "")
1358
+ if (!text) return
1359
+ const parts = text.split("\n")
1360
+ if (!ui.logs.length) ui.logs.push("")
1361
+ ui.logs[ui.logs.length - 1] += parts[0]
1362
+ for (let i = 1; i < parts.length; i++) ui.logs.push(parts[i])
1363
+ if (ui.logs.length > MAX_TUI_LOG_LINES) ui.logs.splice(0, ui.logs.length - MAX_TUI_LOG_LINES)
1364
+ if (follow) ui.scrollOffset = 0
1365
+ requestRender()
1366
+ }
1367
+
1368
+ const activityRenderer = createActivityRenderer({
1369
+ theme: ctx.themeState.theme,
1370
+ output: { appendLog, appendStreamChunk }
1371
+ })
1372
+ activityRenderer.start()
1373
+
1374
+ const uiEventUnsub = EventBus.subscribe((event) => {
1375
+ const { type, payload } = event
1376
+ switch (type) {
1377
+ case EVENT_TYPES.TURN_STEP_START: {
1378
+ ui.currentStep = payload.step || 0
1379
+ ui.maxSteps = Number(ctx.configState.config.agent?.max_steps) || 25
1380
+ ui.currentActivity = { type: "thinking" }
1381
+ requestRender()
1382
+ break
1383
+ }
1384
+ case EVENT_TYPES.TOOL_START:
1385
+ ui.currentActivity = { type: "tool", tool: payload.tool, args: payload.args }
1386
+ requestRender()
1387
+ break
1388
+ case EVENT_TYPES.TOOL_FINISH:
1389
+ case EVENT_TYPES.TOOL_ERROR:
1390
+ ui.currentActivity = { type: "thinking" }
1391
+ requestRender()
1392
+ break
1393
+ case EVENT_TYPES.STREAM_TEXT_START:
1394
+ ui.currentActivity = { type: "writing" }
1395
+ requestRender()
1396
+ break
1397
+ case EVENT_TYPES.STREAM_THINKING_START:
1398
+ ui.currentActivity = { type: "thinking" }
1399
+ requestRender()
1400
+ break
1401
+ case EVENT_TYPES.TURN_USAGE_UPDATE: {
1402
+ const u = payload.usage || {}
1403
+ ui.metrics.tokenMeter = {
1404
+ ...ui.metrics.tokenMeter,
1405
+ estimated: true,
1406
+ turn: { input: u.input || 0, output: u.output || 0 }
1407
+ }
1408
+ // rough cost estimate: opus-class rates with cache differentiation
1409
+ ui.metrics.cost = ((u.input || 0) * 15 + (u.output || 0) * 75 + (u.cacheRead || 0) * 1.5 + (u.cacheWrite || 0) * 18.75) / 1_000_000
1410
+ if (payload.context) ui.metrics.context = payload.context
1411
+ requestRender()
1412
+ break
1413
+ }
1414
+ case EVENT_TYPES.TURN_FINISH:
1415
+ ui.currentActivity = null
1416
+ ui.currentStep = 0
1417
+ requestRender()
1418
+ break
1419
+ }
1420
+ })
1421
+
1422
+ function queuePermissionPrompt(request) {
1423
+ ui.permissionQueue.push(request)
1424
+ if (!ui.pendingPermission) {
1425
+ ui.pendingPermission = ui.permissionQueue.shift() || null
1426
+ ui.permissionSelected = defaultPermissionIndex(ui.pendingPermission)
1427
+ }
1428
+ requestRender({ force: true })
1429
+ }
1430
+
1431
+ function resolvePermissionPrompt(decision) {
1432
+ if (!ui.pendingPermission) return
1433
+ const current = ui.pendingPermission
1434
+ ui.pendingPermission = null
1435
+ ui.permissionSelected = 0
1436
+ try {
1437
+ current.resolve(decision)
1438
+ } catch {}
1439
+ if (ui.permissionQueue.length) {
1440
+ ui.pendingPermission = ui.permissionQueue.shift() || null
1441
+ ui.permissionSelected = defaultPermissionIndex(ui.pendingPermission)
1442
+ }
1443
+ requestRender({ force: true })
1444
+ }
1445
+
1446
+ function defaultPermissionIndex(perm) {
1447
+ if (!perm) return 0
1448
+ const da = perm.defaultAction
1449
+ if (da === "allow" || da === "allow_once") return 0
1450
+ if (da === "allow_session") return 1
1451
+ return 2
1452
+ }
1453
+
1454
+ function queueQuestionPrompt(request) {
1455
+ ui.questionQueue.push(request)
1456
+ if (!ui.pendingQuestion) {
1457
+ activateNextQuestion()
1458
+ }
1459
+ requestRender({ force: true })
1460
+ }
1461
+
1462
+ function activateNextQuestion() {
1463
+ if (ui.questionQueue.length === 0) {
1464
+ ui.pendingQuestion = null
1465
+ return
1466
+ }
1467
+ const next = ui.questionQueue.shift()
1468
+ ui.pendingQuestion = next
1469
+ ui.questionIndex = 0
1470
+ ui.questionOptionSelected = 0
1471
+ ui.questionMultiSelected = {}
1472
+ ui.questionCustomMode = false
1473
+ ui.questionCustomInput = ""
1474
+ ui.questionCustomCursor = 0
1475
+ ui.questionAnswers = {}
1476
+ }
1477
+
1478
+ function commitCurrentQuestionAnswer() {
1479
+ if (!ui.pendingQuestion) return
1480
+ const questions = ui.pendingQuestion.questions || []
1481
+ const q = questions[ui.questionIndex]
1482
+ if (!q) return
1483
+ if (ui.questionCustomMode) {
1484
+ ui.questionAnswers[q.id] = ui.questionCustomInput || ""
1485
+ ui.questionCustomMode = false
1486
+ ui.questionCustomInput = ""
1487
+ ui.questionCustomCursor = 0
1488
+ } else if (q.multi) {
1489
+ const selected = ui.questionMultiSelected[q.id] || new Set()
1490
+ const values = [...selected].map((i) => {
1491
+ const opt = (q.options || [])[i]
1492
+ return opt ? (opt.value || opt.label) : ""
1493
+ }).filter(Boolean)
1494
+ ui.questionAnswers[q.id] = values.join(", ")
1495
+ } else {
1496
+ const opt = (q.options || [])[ui.questionOptionSelected]
1497
+ if (opt) {
1498
+ ui.questionAnswers[q.id] = opt.value || opt.label
1499
+ }
1500
+ }
1501
+ }
1502
+
1503
+ function advanceOrSubmitQuestion() {
1504
+ commitCurrentQuestionAnswer()
1505
+ const questions = ui.pendingQuestion?.questions || []
1506
+ if (ui.questionIndex < questions.length - 1) {
1507
+ ui.questionIndex += 1
1508
+ ui.questionOptionSelected = 0
1509
+ ui.questionCustomMode = false
1510
+ ui.questionCustomInput = ""
1511
+ ui.questionCustomCursor = 0
1512
+ requestRender({ force: true })
1513
+ } else {
1514
+ resolveQuestionPrompt()
1515
+ }
1516
+ }
1517
+
1518
+ function resolveQuestionPrompt() {
1519
+ if (!ui.pendingQuestion) return
1520
+ const current = ui.pendingQuestion
1521
+ const questions = current.questions || []
1522
+ // Ensure all unanswered questions get committed
1523
+ for (let i = 0; i < questions.length; i++) {
1524
+ if (!(questions[i].id in ui.questionAnswers)) {
1525
+ ui.questionAnswers[questions[i].id] = "(skipped)"
1526
+ }
1527
+ }
1528
+ const answers = { ...ui.questionAnswers }
1529
+ ui.pendingQuestion = null
1530
+ ui.questionIndex = 0
1531
+ ui.questionOptionSelected = 0
1532
+ ui.questionMultiSelected = {}
1533
+ ui.questionCustomMode = false
1534
+ ui.questionCustomInput = ""
1535
+ ui.questionCustomCursor = 0
1536
+ ui.questionAnswers = {}
1537
+ try {
1538
+ current.resolve(answers)
1539
+ } catch {}
1540
+ activateNextQuestion()
1541
+ requestRender({ force: true })
1542
+ }
1543
+
1544
+ function buildModelPickerItems() {
1545
+ const items = []
1546
+ const providerConfig = ctx.configState.config.provider || {}
1547
+ for (const [name, conf] of Object.entries(providerConfig)) {
1548
+ if (!conf || typeof conf !== "object" || !conf.models) continue
1549
+ for (const model of conf.models) {
1550
+ items.push({ provider: name, model, label: `${name} / ${model}` })
1551
+ }
1552
+ }
1553
+ return items
1554
+ }
1555
+
1556
+ function openModelPicker() {
1557
+ const items = buildModelPickerItems()
1558
+ if (!items.length) {
1559
+ appendLog(paint("No models configured. Add `models` array to provider config.", ctx.themeState.theme.semantic.error))
1560
+ requestRender()
1561
+ return
1562
+ }
1563
+ const currentIdx = items.findIndex((it) => it.model === state.model && it.provider === state.providerType)
1564
+ ui.modelPicker = {
1565
+ items,
1566
+ selected: Math.max(0, currentIdx),
1567
+ offset: 0
1568
+ }
1569
+ requestRender({ force: true })
1570
+ }
1571
+
1572
+ function closeModelPicker() {
1573
+ ui.modelPicker = null
1574
+ requestRender({ force: true })
1575
+ }
1576
+
1577
+ function confirmModelPicker() {
1578
+ if (!ui.modelPicker) return
1579
+ const chosen = ui.modelPicker.items[ui.modelPicker.selected]
1580
+ if (chosen) {
1581
+ state.providerType = chosen.provider
1582
+ state.model = chosen.model
1583
+ appendLog(paint(`model switched: ${chosen.provider} / ${chosen.model}`, ctx.themeState.theme.semantic.success))
1584
+ }
1585
+ closeModelPicker()
1586
+ }
1587
+
1588
+ const POLICY_CHOICES = [
1589
+ { label: "Ask", value: "ask", desc: "prompt before each tool call" },
1590
+ { label: "Allow", value: "allow", desc: "allow all tool calls" },
1591
+ { label: "Deny", value: "deny", desc: "deny all tool calls" },
1592
+ { label: "Session Clear", value: "session-clear", desc: "clear cached grants" }
1593
+ ]
1594
+
1595
+ function openPolicyPicker() {
1596
+ const current = ctx.configState.config.permission?.default_policy || "ask"
1597
+ const idx = POLICY_CHOICES.findIndex((c) => c.value === current)
1598
+ ui.policyPicker = { selected: Math.max(0, idx) }
1599
+ requestRender({ force: true })
1600
+ }
1601
+
1602
+ function closePolicyPicker() {
1603
+ ui.policyPicker = null
1604
+ requestRender({ force: true })
1605
+ }
1606
+
1607
+ function confirmPolicyPicker() {
1608
+ if (!ui.policyPicker) return
1609
+ const chosen = POLICY_CHOICES[ui.policyPicker.selected]
1610
+ if (chosen) {
1611
+ if (chosen.value === "session-clear") {
1612
+ PermissionEngine.clearSession(state.sessionId)
1613
+ appendLog(paint(`permission session cache cleared`, ctx.themeState.theme.semantic.success))
1614
+ } else {
1615
+ const permission = ctx.configState.config.permission || (ctx.configState.config.permission = {})
1616
+ permission.default_policy = chosen.value
1617
+ appendLog(paint(`permission policy → ${chosen.value}`, ctx.themeState.theme.semantic.success))
1618
+ }
1619
+ }
1620
+ closePolicyPicker()
1621
+ }
1622
+
1623
+ function setInputFromHistory(value) {
1624
+ ui.input = value || ""
1625
+ ui.inputCursor = ui.input.length
1626
+ }
1627
+
1628
+ function insertAtCursor(text) {
1629
+ if (!text) return
1630
+ const head = ui.input.slice(0, ui.inputCursor)
1631
+ const tail = ui.input.slice(ui.inputCursor)
1632
+ ui.input = `${head}${text}${tail}`
1633
+ ui.inputCursor += text.length
1634
+ }
1635
+
1636
+ function moveCursor(delta) {
1637
+ ui.inputCursor = Math.max(0, Math.min(ui.input.length, ui.inputCursor + delta))
1638
+ }
1639
+
1640
+ function setCursor(pos) {
1641
+ ui.inputCursor = Math.max(0, Math.min(ui.input.length, pos))
1642
+ }
1643
+
1644
+ function scrollBy(delta) {
1645
+ const max = ui.scrollMeta.maxOffset || 0
1646
+ ui.scrollOffset = Math.max(0, Math.min(max, ui.scrollOffset + delta))
1647
+ }
1648
+
1649
+ function scrollToTop() {
1650
+ ui.scrollOffset = ui.scrollMeta.maxOffset || 0
1651
+ }
1652
+
1653
+ function scrollToBottom() {
1654
+ ui.scrollOffset = 0
1655
+ }
1656
+
1657
+ function buildFrame() {
1658
+ const width = Number(process.stdout.columns || 120)
1659
+ const height = Number(process.stdout.rows || 40)
1660
+
1661
+ const dashboardLines = ui.showDashboard
1662
+ ? renderReplLogo({
1663
+ theme: ctx.themeState.theme,
1664
+ columns: width
1665
+ }).split("\n")
1666
+ : []
1667
+
1668
+ const suggestions = slashSuggestions(ui.input, localCustomCommands)
1669
+ if (suggestions.length === 0) {
1670
+ ui.selectedSuggestion = 0
1671
+ ui.suggestionOffset = 0
1672
+ } else if (ui.selectedSuggestion >= suggestions.length) {
1673
+ ui.selectedSuggestion = suggestions.length - 1
1674
+ }
1675
+ const suggestionRender = renderSuggestions({
1676
+ inputLine: ui.input,
1677
+ suggestions,
1678
+ selected: ui.selectedSuggestion,
1679
+ offset: ui.suggestionOffset,
1680
+ maxVisible: MAX_TUI_SUGGESTIONS,
1681
+ theme: ctx.themeState.theme,
1682
+ width: Math.max(1, width - 4)
1683
+ })
1684
+ const suggestionLines = suggestionRender.lines
1685
+ ui.suggestionOffset = suggestionRender.offset
1686
+
1687
+ const status = renderStatusBar({
1688
+ mode: state.mode,
1689
+ model: state.model,
1690
+ permission: ctx.configState.config.permission.default_policy,
1691
+ tokenMeter: ui.metrics.tokenMeter,
1692
+ aggregation: ctx.configState.config.usage.aggregation,
1693
+ cost: ui.metrics.cost,
1694
+ savings: ui.metrics.costSavings,
1695
+ contextMeter: ui.metrics.context,
1696
+ showCost: ctx.configState.config.ui.status.show_cost,
1697
+ showTokenMeter: ctx.configState.config.ui.status.show_token_meter,
1698
+ theme: ctx.themeState.theme,
1699
+ layout: ctx.configState.config.ui.layout,
1700
+ longagentState: state.mode === "longagent" ? ui.metrics.longagent : null,
1701
+ memoryLoaded: state.memoryLoaded
1702
+ })
1703
+
1704
+ const lines = []
1705
+ let dashboardRows = 0
1706
+ if (ui.showDashboard && dashboardLines.length) {
1707
+ dashboardRows = Math.min(dashboardLines.length, Math.max(5, Math.floor(height * 0.22)))
1708
+ lines.push(...dashboardLines.slice(0, dashboardRows).map((line) => clipAnsiLine(line, width)))
1709
+ lines.push(" ".repeat(width))
1710
+ }
1711
+
1712
+ const inputInnerWidth = Math.max(8, width - 4)
1713
+ const cursorMark = "▌"
1714
+ const before = ui.input.slice(0, ui.inputCursor)
1715
+ const after = ui.input.slice(ui.inputCursor)
1716
+ const imgTag = ui.pendingImages.length ? `[${ui.pendingImages.length} img] ` : ""
1717
+ const inputDecorated = `${ui.busy ? "[running] " : "[ready] "}${imgTag}> ${before}${cursorMark}${after}`
1718
+ const inputLogical = inputDecorated.split("\n")
1719
+ const inputWrapped = []
1720
+ for (const logicalLine of inputLogical) {
1721
+ const wrapped = wrapPlainLine(logicalLine, inputInnerWidth)
1722
+ for (const part of wrapped) inputWrapped.push(part)
1723
+ }
1724
+ const inputVisibleRows = Math.max(1, Math.min(5, Math.floor(height * 0.2)))
1725
+ const visibleInput = inputWrapped.slice(-inputVisibleRows)
1726
+ let busyLine
1727
+ if (ui.busy && ui.currentActivity) {
1728
+ const spinner = BUSY_SPINNER_FRAMES[ui.spinnerIndex]
1729
+ const stepTag = ui.currentStep > 0
1730
+ ? paint(` [${ui.currentStep}/${ui.maxSteps || "?"}]`, "cyan", { dim: true })
1731
+ : ""
1732
+ if (ui.currentActivity.type === "tool") {
1733
+ const toolName = ui.currentActivity.tool || "tool"
1734
+ const toolColor = toolName === "edit" || toolName === "write" || toolName === "notebookedit" ? "yellow"
1735
+ : toolName === "bash" ? "magenta"
1736
+ : "cyan"
1737
+ busyLine = `${paint(spinner, toolColor)} ${paint(toolName, toolColor, { bold: true })}${formatBusyToolDetail(toolName, ui.currentActivity.args)}${stepTag}`
1738
+ } else if (ui.currentActivity.type === "writing") {
1739
+ busyLine = `${paint(spinner, "green")} ${paint("writing", "green", { bold: true })}${stepTag}`
1740
+ } else {
1741
+ busyLine = `${paint(spinner, ctx.themeState.theme.semantic.warn)} ${paint("thinking", ctx.themeState.theme.semantic.warn, { bold: true })}${stepTag}`
1742
+ }
1743
+ } else if (ui.busy) {
1744
+ const spinner = BUSY_SPINNER_FRAMES[ui.spinnerIndex]
1745
+ busyLine = `${paint(spinner, ctx.themeState.theme.semantic.warn)} ${paint("thinking", ctx.themeState.theme.semantic.warn, { bold: true })}`
1746
+ } else {
1747
+ busyLine = ""
1748
+ }
1749
+
1750
+ const suggestionBlock = suggestionLines.length ? suggestionLines.length + 1 : 0
1751
+ const PERM_CHOICES = [
1752
+ { label: "Allow Once", value: "allow_once" },
1753
+ { label: "Allow Session", value: "allow_session" },
1754
+ { label: "Deny", value: "deny" }
1755
+ ]
1756
+ const permissionLines = []
1757
+ if (ui.pendingPermission) {
1758
+ const perm = ui.pendingPermission
1759
+ const toolInfo = `tool: ${perm.tool}`
1760
+ const reasonInfo = perm.reason ? ` ${perm.reason}` : ""
1761
+ permissionLines.push(
1762
+ paint(`Permission Request ↑↓ navigate Enter select Esc deny`, ctx.themeState.theme.semantic.warn, { bold: true })
1763
+ )
1764
+ permissionLines.push(paint(`┌${"─".repeat(Math.max(1, width - 4))}┐`, ctx.themeState.theme.base.border))
1765
+ permissionLines.push(paint(`│ ${padRight(toolInfo, Math.max(1, width - 5))}│`, ctx.themeState.theme.base.fg))
1766
+ if (reasonInfo) {
1767
+ permissionLines.push(paint(`│ ${padRight(reasonInfo, Math.max(1, width - 5))}│`, ctx.themeState.theme.base.muted))
1768
+ }
1769
+ permissionLines.push(paint(`│${"─".repeat(Math.max(1, width - 4))}│`, ctx.themeState.theme.base.border))
1770
+ for (let i = 0; i < PERM_CHOICES.length; i++) {
1771
+ const choice = PERM_CHOICES[i]
1772
+ const active = i === ui.permissionSelected
1773
+ const prefix = active ? "▸" : " "
1774
+ const line = ` ${prefix} ${choice.label}`
1775
+ permissionLines.push(
1776
+ active
1777
+ ? paint(`│${padRight(line, Math.max(1, width - 5))}│`, "#111111", { bg: ctx.themeState.theme.semantic.warn, bold: true })
1778
+ : paint(`│${padRight(line, Math.max(1, width - 5))}│`, ctx.themeState.theme.base.fg)
1779
+ )
1780
+ }
1781
+ permissionLines.push(paint(`└${"─".repeat(Math.max(1, width - 4))}┘`, ctx.themeState.theme.base.border))
1782
+ }
1783
+ const modelPickerLines = []
1784
+ if (ui.modelPicker) {
1785
+ const mp = ui.modelPicker
1786
+ const visible = Math.min(mp.items.length, MAX_MODEL_PICKER_VISIBLE)
1787
+ let start = Math.max(0, Math.min(mp.offset, mp.items.length - visible))
1788
+ if (mp.selected < start) start = mp.selected
1789
+ if (mp.selected >= start + visible) start = mp.selected - visible + 1
1790
+ mp.offset = start
1791
+ const end = Math.min(mp.items.length, start + visible)
1792
+ modelPickerLines.push(
1793
+ paint(`Select Model (${mp.selected + 1}/${mp.items.length}) ↑↓ navigate Enter select Esc cancel`, ctx.themeState.theme.semantic.info, { bold: true })
1794
+ )
1795
+ modelPickerLines.push(paint(`┌${"─".repeat(Math.max(1, width - 4))}┐`, ctx.themeState.theme.base.border))
1796
+ for (let i = start; i < end; i++) {
1797
+ const item = mp.items[i]
1798
+ const active = i === mp.selected
1799
+ const current = item.model === state.model && item.provider === state.providerType
1800
+ const marker = current ? "●" : " "
1801
+ const prefix = active ? "▸" : " "
1802
+ const line = ` ${prefix} ${marker} ${item.label}`
1803
+ const padded = padRight(line, Math.max(1, width - 5))
1804
+ modelPickerLines.push(
1805
+ active
1806
+ ? paint(`│${padded}│`, "#111111", { bg: ctx.themeState.theme.semantic.info, bold: true })
1807
+ : paint(`│${padded}│`, current ? ctx.themeState.theme.semantic.success : ctx.themeState.theme.base.fg)
1808
+ )
1809
+ }
1810
+ modelPickerLines.push(paint(`└${"─".repeat(Math.max(1, width - 4))}┘`, ctx.themeState.theme.base.border))
1811
+ if (mp.items.length > visible) {
1812
+ modelPickerLines.push(paint(` ${start + 1}-${end} of ${mp.items.length}`, ctx.themeState.theme.base.muted))
1813
+ }
1814
+ }
1815
+ const modelPickerBlock = modelPickerLines.length ? modelPickerLines.length : 0
1816
+ const policyPickerLines = []
1817
+ if (ui.policyPicker) {
1818
+ const currentPolicy = ctx.configState.config.permission?.default_policy || "ask"
1819
+ policyPickerLines.push(
1820
+ paint(`Permission Policy ↑↓ navigate Enter select Esc cancel`, ctx.themeState.theme.semantic.info, { bold: true })
1821
+ )
1822
+ policyPickerLines.push(paint(`┌${"─".repeat(Math.max(1, width - 4))}┐`, ctx.themeState.theme.base.border))
1823
+ for (let i = 0; i < POLICY_CHOICES.length; i++) {
1824
+ const choice = POLICY_CHOICES[i]
1825
+ const active = i === ui.policyPicker.selected
1826
+ const current = choice.value === currentPolicy
1827
+ const marker = current ? "●" : " "
1828
+ const prefix = active ? "▸" : " "
1829
+ policyPickerLines.push(
1830
+ active
1831
+ ? paint(`│${padRight(` ${prefix} ${marker} ${choice.label} ${choice.desc}`, Math.max(1, width - 5))}│`, "#111111", { bg: ctx.themeState.theme.semantic.info, bold: true })
1832
+ : paint(`│${padRight(` ${prefix} ${marker} ${choice.label}`, 22)}${padRight(choice.desc, Math.max(1, width - 27))}│`, current ? ctx.themeState.theme.semantic.success : ctx.themeState.theme.base.fg)
1833
+ )
1834
+ }
1835
+ policyPickerLines.push(paint(`└${"─".repeat(Math.max(1, width - 4))}┘`, ctx.themeState.theme.base.border))
1836
+ }
1837
+ const policyPickerBlock = policyPickerLines.length
1838
+ const permissionBlock = permissionLines.length
1839
+
1840
+ // --- Question panel ---
1841
+ const questionLines = []
1842
+ if (ui.pendingQuestion) {
1843
+ const pq = ui.pendingQuestion
1844
+ const questions = pq.questions || []
1845
+ const qCount = questions.length
1846
+ const currentQ = questions[ui.questionIndex] || {}
1847
+ const options = Array.isArray(currentQ.options) ? currentQ.options : []
1848
+ const answered = Object.keys(ui.questionAnswers).length
1849
+
1850
+ // Header
1851
+ const hintKeys = ui.questionCustomMode
1852
+ ? "Enter confirm Esc back"
1853
+ : "↑↓ select Enter confirm Tab switch Esc skip Ctrl+Enter submit all"
1854
+ questionLines.push(
1855
+ paint(`Question (${ui.questionIndex + 1}/${qCount}) ${hintKeys}`, ctx.themeState.theme.semantic.info, { bold: true })
1856
+ )
1857
+ questionLines.push(paint(`┌${"─".repeat(Math.max(1, width - 4))}┐`, ctx.themeState.theme.base.border))
1858
+
1859
+ // Tab bar (multi-question)
1860
+ if (qCount > 1) {
1861
+ let tabBar = ""
1862
+ for (let i = 0; i < qCount; i++) {
1863
+ const qId = questions[i].id
1864
+ const done = qId in ui.questionAnswers
1865
+ const isCurrent = i === ui.questionIndex
1866
+ const marker = done ? "✓" : " "
1867
+ const tabLabel = (questions[i].header || `Q${i + 1}`).slice(0, 12)
1868
+ tabBar += isCurrent ? `[${marker}${tabLabel}]` : ` ${marker}${tabLabel} `
1869
+ if (i < qCount - 1) tabBar += " "
1870
+ }
1871
+ questionLines.push(paint(`│ ${padRight(tabBar, Math.max(1, width - 5))}│`, ctx.themeState.theme.base.fg))
1872
+ questionLines.push(paint(`│${"─".repeat(Math.max(1, width - 4))}│`, ctx.themeState.theme.base.border))
1873
+ }
1874
+
1875
+ // Question text
1876
+ questionLines.push(paint(`│ ${padRight(currentQ.text || "", Math.max(1, width - 5))}│`, ctx.themeState.theme.base.fg))
1877
+ if (currentQ.description) {
1878
+ questionLines.push(paint(`│ ${padRight(currentQ.description, Math.max(1, width - 5))}│`, ctx.themeState.theme.base.muted))
1879
+ }
1880
+ questionLines.push(paint(`│${"─".repeat(Math.max(1, width - 4))}│`, ctx.themeState.theme.base.border))
1881
+
1882
+ if (ui.questionCustomMode) {
1883
+ // Custom input mode
1884
+ const inputDisplay = ui.questionCustomInput || ""
1885
+ questionLines.push(
1886
+ paint(`│ ${padRight("Custom input:", Math.max(1, width - 5))}│`, ctx.themeState.theme.base.muted)
1887
+ )
1888
+ questionLines.push(
1889
+ paint(`│ ${padRight(inputDisplay || "(type your answer)", Math.max(1, width - 5))}│`, ctx.themeState.theme.base.fg)
1890
+ )
1891
+ } else if (options.length) {
1892
+ // Options list
1893
+ const multiSelected = ui.questionMultiSelected[currentQ.id] || new Set()
1894
+ for (let i = 0; i < options.length; i++) {
1895
+ const opt = options[i]
1896
+ const active = i === ui.questionOptionSelected
1897
+ const prefix = active ? "▸" : " "
1898
+ let marker
1899
+ if (currentQ.multi) {
1900
+ marker = multiSelected.has(i) ? "☑" : "☐"
1901
+ } else {
1902
+ marker = active ? "●" : "○"
1903
+ }
1904
+ const optLine = ` ${prefix} ${marker} ${opt.label}`
1905
+ questionLines.push(
1906
+ active
1907
+ ? paint(`│${padRight(optLine, Math.max(1, width - 5))}│`, "#111111", { bg: ctx.themeState.theme.semantic.info, bold: true })
1908
+ : paint(`│${padRight(optLine, Math.max(1, width - 5))}│`, ctx.themeState.theme.base.fg)
1909
+ )
1910
+ if (opt.description) {
1911
+ questionLines.push(paint(`│${padRight(` ${opt.description}`, Math.max(1, width - 5))}│`, ctx.themeState.theme.base.muted))
1912
+ }
1913
+ }
1914
+ // Custom option
1915
+ if (currentQ.allowCustom !== false) {
1916
+ const customIdx = options.length
1917
+ const active = ui.questionOptionSelected === customIdx
1918
+ const prefix = active ? "▸" : " "
1919
+ const customLine = ` ${prefix} Custom...`
1920
+ questionLines.push(
1921
+ active
1922
+ ? paint(`│${padRight(customLine, Math.max(1, width - 5))}│`, "#111111", { bg: ctx.themeState.theme.semantic.info, bold: true })
1923
+ : paint(`│${padRight(customLine, Math.max(1, width - 5))}│`, ctx.themeState.theme.base.muted)
1924
+ )
1925
+ }
1926
+ } else {
1927
+ // No options — free text only
1928
+ const inputDisplay = ui.questionCustomInput || ""
1929
+ questionLines.push(
1930
+ paint(`│ ${padRight(inputDisplay || "(type your answer)", Math.max(1, width - 5))}│`, ctx.themeState.theme.base.fg)
1931
+ )
1932
+ }
1933
+
1934
+ // Footer
1935
+ questionLines.push(paint(`│${"─".repeat(Math.max(1, width - 4))}│`, ctx.themeState.theme.base.border))
1936
+ const multiCount = currentQ.multi ? (ui.questionMultiSelected[currentQ.id] || new Set()).size : 0
1937
+ const multiHint = currentQ.multi && multiCount > 0 ? ` (${multiCount} selected)` : ""
1938
+ const footerText = `Answered: ${answered}/${qCount}${multiHint} [Ctrl+Enter submit all]`
1939
+ questionLines.push(paint(`│ ${padRight(footerText, Math.max(1, width - 5))}│`, ctx.themeState.theme.base.muted))
1940
+ questionLines.push(paint(`└${"─".repeat(Math.max(1, width - 4))}┘`, ctx.themeState.theme.base.border))
1941
+ }
1942
+ const questionBlock = questionLines.length
1943
+
1944
+ const fixedRows =
1945
+ 1 + // activity title
1946
+ 1 + // scroll hint
1947
+ suggestionBlock +
1948
+ modelPickerBlock +
1949
+ policyPickerBlock +
1950
+ permissionBlock +
1951
+ questionBlock +
1952
+ 1 + // status bar
1953
+ 1 + // busy indicator
1954
+ 1 + // input top border
1955
+ visibleInput.length +
1956
+ 1 + // input bottom border
1957
+ 1 // footer hint
1958
+
1959
+ const logRows = Math.max(2, height - lines.length - fixedRows)
1960
+ const wrappedAllLogs = wrapLogLines(ui.logs, width)
1961
+ const maxOffset = Math.max(0, wrappedAllLogs.length - logRows)
1962
+ if (ui.scrollOffset > maxOffset) ui.scrollOffset = maxOffset
1963
+ const end = Math.max(0, wrappedAllLogs.length - ui.scrollOffset)
1964
+ const start = Math.max(0, end - logRows)
1965
+ const wrappedLogs = wrappedAllLogs.slice(start, end)
1966
+ ui.scrollMeta = {
1967
+ logRows,
1968
+ totalRows: wrappedAllLogs.length,
1969
+ maxOffset
1970
+ }
1971
+
1972
+ const scrollHint = ui.scrollOffset > 0
1973
+ ? paint(` Ctrl+Up/Down scroll | +${ui.scrollOffset} lines`, ctx.themeState.theme.semantic.warn)
1974
+ : paint(" Ctrl+Up/Down scroll | Ctrl+Home oldest | Ctrl+End latest", ctx.themeState.theme.base.muted, { dim: true })
1975
+
1976
+ lines.push(clipAnsiLine(paint("─".repeat(Math.min(40, width)), ctx.themeState.theme.base.border, { dim: true }), width))
1977
+
1978
+ // Scrollbar calculation
1979
+ const totalLog = wrappedAllLogs.length
1980
+ const showScrollbar = totalLog > logRows
1981
+ let thumbStart = 0, thumbEnd = 0
1982
+ if (showScrollbar) {
1983
+ const viewStart = start
1984
+ thumbStart = Math.floor((viewStart / totalLog) * logRows)
1985
+ thumbEnd = Math.min(logRows, thumbStart + Math.max(1, Math.round((logRows / totalLog) * logRows)))
1986
+ }
1987
+
1988
+ for (let i = 0; i < logRows; i++) {
1989
+ const content = wrappedLogs[i] || ""
1990
+ if (showScrollbar) {
1991
+ const bar = i >= thumbStart && i < thumbEnd
1992
+ ? paint("┃", ctx.themeState.theme.semantic.warn)
1993
+ : paint("│", ctx.themeState.theme.base.border, { dim: true })
1994
+ lines.push(clipAnsiLine(content, width - 2) + " " + bar)
1995
+ } else {
1996
+ lines.push(clipAnsiLine(content, width))
1997
+ }
1998
+ }
1999
+
2000
+ lines.push(clipAnsiLine(scrollHint, width))
2001
+
2002
+ if (suggestionLines.length) {
2003
+ lines.push(clipAnsiLine(paint("Commands", ctx.themeState.theme.base.muted, { bold: true }), width))
2004
+ for (const line of suggestionLines) lines.push(clipAnsiLine(line, width))
2005
+ }
2006
+
2007
+ if (modelPickerLines.length) {
2008
+ for (const line of modelPickerLines) lines.push(clipAnsiLine(line, width))
2009
+ }
2010
+
2011
+ if (policyPickerLines.length) {
2012
+ for (const line of policyPickerLines) lines.push(clipAnsiLine(line, width))
2013
+ }
2014
+
2015
+ if (permissionLines.length) {
2016
+ for (const line of permissionLines) lines.push(clipAnsiLine(line, width))
2017
+ }
2018
+
2019
+ if (questionLines.length) {
2020
+ for (const line of questionLines) lines.push(clipAnsiLine(line, width))
2021
+ }
2022
+
2023
+ lines.push(clipAnsiLine(status, width))
2024
+ lines.push(clipAnsiLine(busyLine, width))
2025
+
2026
+ const inputTop = paint(`┌${"─".repeat(Math.max(1, width - 2))}┐`, ctx.themeState.theme.base.border)
2027
+ const inputBottom = paint(`└${"─".repeat(Math.max(1, width - 2))}┘`, ctx.themeState.theme.base.border)
2028
+ lines.push(inputTop)
2029
+ for (const inputLine of visibleInput) {
2030
+ const left = paint("│ ", ctx.themeState.theme.base.border)
2031
+ const right = paint(" │", ctx.themeState.theme.base.border)
2032
+ lines.push(`${left}${clipAnsiLine(inputLine, inputInnerWidth)}${right}`)
2033
+ }
2034
+ lines.push(inputBottom)
2035
+ lines.push(clipAnsiLine(paint("? for shortcuts | Enter send | Ctrl+J newline | /paste image", ctx.themeState.theme.base.muted), width))
2036
+
2037
+ const final = lines.slice(0, Math.max(1, height))
2038
+ while (final.length < height) final.push(" ".repeat(width))
2039
+
2040
+ return { lines: final, width, height }
2041
+ }
2042
+
2043
+ function paintFrame(frame) {
2044
+ if (!frame || !Array.isArray(frame.lines)) return
2045
+ const patches = []
2046
+
2047
+ if (forceFullPaint || frame.width !== lastFrameWidth || lastFrame.length !== frame.lines.length) {
2048
+ patches.push("\x1b[H")
2049
+ patches.push(frame.lines.join("\n"))
2050
+ } else {
2051
+ for (let i = 0; i < frame.lines.length; i++) {
2052
+ const next = frame.lines[i]
2053
+ const prev = lastFrame[i]
2054
+ if (next !== prev) patches.push(`\x1b[${i + 1};1H${next}`)
2055
+ }
2056
+ }
2057
+
2058
+ if (patches.length) output.write(patches.join(""))
2059
+ lastFrame = frame.lines
2060
+ lastFrameWidth = frame.width
2061
+ forceFullPaint = false
2062
+ }
2063
+
2064
+ function requestRender({ force = false } = {}) {
2065
+ if (force) forceFullPaint = true
2066
+ if (renderScheduled) return
2067
+ renderScheduled = true
2068
+ renderTimer = setTimeout(() => {
2069
+ renderScheduled = false
2070
+ renderTimer = null
2071
+ paintFrame(buildFrame())
2072
+ }, TUI_FRAME_MS)
2073
+ }
2074
+
2075
+ function startBusySpinner() {
2076
+ if (spinnerTimer) return
2077
+ spinnerTimer = setInterval(() => {
2078
+ ui.spinnerIndex = (ui.spinnerIndex + 1) % BUSY_SPINNER_FRAMES.length
2079
+ requestRender()
2080
+ }, 120)
2081
+ }
2082
+
2083
+ function stopBusySpinner() {
2084
+ if (!spinnerTimer) return
2085
+ clearInterval(spinnerTimer)
2086
+ spinnerTimer = null
2087
+ }
2088
+
2089
+ async function submitCurrentInput() {
2090
+ const line = ui.input.replace(/\r/g, "")
2091
+ if (!line.trim() || ui.busy) return
2092
+
2093
+ ui.history.push(line)
2094
+ if (ui.history.length > HIST_SIZE) ui.history.splice(0, ui.history.length - HIST_SIZE)
2095
+ ui.historyIndex = ui.history.length
2096
+
2097
+ appendLog(`> ${line}`)
2098
+ appendLog("")
2099
+ ui.input = ""
2100
+ ui.inputCursor = 0
2101
+ ui.selectedSuggestion = 0
2102
+ ui.suggestionOffset = 0
2103
+ ui.busy = true
2104
+ startBusySpinner()
2105
+ requestRender()
2106
+
2107
+ try {
2108
+ const action = await processInputLine({
2109
+ line,
2110
+ state,
2111
+ ctx,
2112
+ providersConfigured,
2113
+ customCommands: localCustomCommands,
2114
+ setCustomCommands: (next) => {
2115
+ localCustomCommands = next
2116
+ },
2117
+ print: appendLog,
2118
+ streamSink: appendStreamChunk,
2119
+ showTurnStatus: false,
2120
+ pendingImages: ui.pendingImages,
2121
+ clearPendingImages: () => { ui.pendingImages = [] }
2122
+ })
2123
+
2124
+ if (action.cleared) {
2125
+ ui.logs = []
2126
+ }
2127
+ if (action.dashboardRefresh) {
2128
+ localRecentSessions = action.recentSessions || localRecentSessions
2129
+ ui.showDashboard = true
2130
+ appendLog("dashboard refreshed")
2131
+ }
2132
+ if (action.turnResult) {
2133
+ ui.metrics.tokenMeter = action.turnResult.tokenMeter || ui.metrics.tokenMeter
2134
+ ui.metrics.cost = Number.isFinite(action.turnResult.cost) ? action.turnResult.cost : ui.metrics.cost
2135
+ ui.metrics.costSavings = action.turnResult.costSavings ?? 0
2136
+ if (action.turnResult.context) ui.metrics.context = action.turnResult.context
2137
+ ui.metrics.longagent = action.turnResult.longagent || null
2138
+ ui.metrics.toolEvents = action.turnResult.toolEvents || []
2139
+ }
2140
+ if (!action.dashboardRefresh && !line.startsWith("/")) ui.showDashboard = false
2141
+ if (action.openModelPicker) {
2142
+ openModelPicker()
2143
+ }
2144
+ if (action.openPolicyPicker) {
2145
+ openPolicyPicker()
2146
+ }
2147
+ if (action.exit) {
2148
+ ui.quitting = true
2149
+ }
2150
+ } catch (error) {
2151
+ appendLog(`error: ${error.message}`)
2152
+ } finally {
2153
+ ui.busy = false
2154
+ ui.currentActivity = null
2155
+ stopBusySpinner()
2156
+ requestRender()
2157
+ }
2158
+ }
2159
+
2160
+ function handleUpDownSuggestions(keyName) {
2161
+ const suggestions = slashSuggestions(ui.input, localCustomCommands)
2162
+ if (suggestions.length > 0 && String(ui.input || "").startsWith("/")) {
2163
+ if (keyName === "up") {
2164
+ ui.selectedSuggestion = Math.max(0, ui.selectedSuggestion - 1)
2165
+ } else {
2166
+ ui.selectedSuggestion = Math.min(suggestions.length - 1, ui.selectedSuggestion + 1)
2167
+ }
2168
+ return true
2169
+ }
2170
+ return false
2171
+ }
2172
+
2173
+ function navigateHistory(keyName) {
2174
+ if (!ui.history.length) return
2175
+ if (keyName === "up") {
2176
+ if (ui.historyIndex > 0) ui.historyIndex -= 1
2177
+ setInputFromHistory(ui.history[ui.historyIndex] || "")
2178
+ return
2179
+ }
2180
+ if (ui.historyIndex < ui.history.length - 1) {
2181
+ ui.historyIndex += 1
2182
+ setInputFromHistory(ui.history[ui.historyIndex] || "")
2183
+ return
2184
+ }
2185
+ ui.historyIndex = ui.history.length
2186
+ setInputFromHistory("")
2187
+ }
2188
+
2189
+ function applyCurrentSuggestion() {
2190
+ const suggestions = slashSuggestions(ui.input, localCustomCommands)
2191
+ if (!suggestions.length) return
2192
+ const chosen = suggestions[Math.max(0, Math.min(ui.selectedSuggestion, suggestions.length - 1))]
2193
+ ui.input = applySuggestionToInput(ui.input, chosen.name)
2194
+ ui.inputCursor = ui.input.length
2195
+ }
2196
+
2197
+ function shouldApplySuggestionOnEnter() {
2198
+ const suggestions = slashSuggestions(ui.input, localCustomCommands)
2199
+ if (!suggestions.length) return false
2200
+ if (!String(ui.input || "").startsWith("/")) return false
2201
+ const body = String(ui.input || "").slice(1)
2202
+ const firstSpace = body.indexOf(" ")
2203
+ if (firstSpace >= 0) return false
2204
+ const token = body.trim()
2205
+ if (!token) return true
2206
+ const chosen = suggestions[Math.max(0, Math.min(ui.selectedSuggestion, suggestions.length - 1))]
2207
+ return chosen && chosen.name !== token
2208
+ }
2209
+
2210
+ function cycleModeForwardAndNotify() {
2211
+ const next = cycleMode(state)
2212
+ appendLog(`mode switched: ${next}`)
2213
+ requestRender()
2214
+ }
2215
+
2216
+ startTuiFrame()
2217
+ setPermissionPromptHandler(({ tool, sessionId, reason = "", defaultAction = "deny" }) =>
2218
+ new Promise((resolve) => {
2219
+ queuePermissionPrompt({
2220
+ tool,
2221
+ sessionId,
2222
+ reason,
2223
+ defaultAction,
2224
+ resolve
2225
+ })
2226
+ })
2227
+ )
2228
+ setQuestionPromptHandler(({ questions }) =>
2229
+ new Promise((resolve) => {
2230
+ queueQuestionPrompt({ questions, resolve })
2231
+ })
2232
+ )
2233
+ emitKeypressEvents(process.stdin)
2234
+ if (process.stdin.isTTY) process.stdin.setRawMode(true)
2235
+ process.stdin.resume()
2236
+
2237
+ paintFrame(buildFrame())
2238
+
2239
+ let onResize = null
2240
+ let onKey = null
2241
+ let onData = null
2242
+ let onSigint = null
2243
+ try {
2244
+ await new Promise((resolve) => {
2245
+ let finished = false
2246
+ const finish = () => {
2247
+ if (finished) return
2248
+ finished = true
2249
+ resolve()
2250
+ }
2251
+
2252
+ onResize = () => requestRender({ force: true })
2253
+ onKey = async (str, key = {}) => {
2254
+ if (ui.quitting) return
2255
+
2256
+ if (key.ctrl && key.name === "c") {
2257
+ ui.quitting = true
2258
+ finish()
2259
+ return
2260
+ }
2261
+
2262
+ if (key.ctrl && key.name === "d") {
2263
+ ui.quitting = true
2264
+ finish()
2265
+ return
2266
+ }
2267
+
2268
+ if (ui.pendingPermission) {
2269
+ const PERM_VALUES = ["allow_once", "allow_session", "deny"]
2270
+ if (key.name === "escape") {
2271
+ resolvePermissionPrompt("deny")
2272
+ return
2273
+ }
2274
+ if (key.name === "return") {
2275
+ resolvePermissionPrompt(PERM_VALUES[ui.permissionSelected] || "deny")
2276
+ return
2277
+ }
2278
+ if (key.name === "up") {
2279
+ ui.permissionSelected = Math.max(0, ui.permissionSelected - 1)
2280
+ requestRender()
2281
+ return
2282
+ }
2283
+ if (key.name === "down") {
2284
+ ui.permissionSelected = Math.min(PERM_VALUES.length - 1, ui.permissionSelected + 1)
2285
+ requestRender()
2286
+ return
2287
+ }
2288
+ return
2289
+ }
2290
+
2291
+ if (ui.pendingQuestion) {
2292
+ const questions = ui.pendingQuestion.questions || []
2293
+ const currentQ = questions[ui.questionIndex] || {}
2294
+ const options = Array.isArray(currentQ.options) ? currentQ.options : []
2295
+ const maxOptIdx = options.length + (currentQ.allowCustom !== false ? 1 : 0) - 1
2296
+
2297
+ // Ctrl+Enter: submit all answers immediately
2298
+ if (key.ctrl && key.name === "return") {
2299
+ commitCurrentQuestionAnswer()
2300
+ resolveQuestionPrompt()
2301
+ return
2302
+ }
2303
+
2304
+ if (ui.questionCustomMode || options.length === 0) {
2305
+ // Custom text input mode / free text question
2306
+ if (key.name === "escape" && options.length > 0) {
2307
+ // Back to options list
2308
+ ui.questionCustomMode = false
2309
+ requestRender()
2310
+ return
2311
+ }
2312
+ if (key.name === "escape" && options.length === 0) {
2313
+ // Skip this question
2314
+ ui.questionAnswers[currentQ.id] = "(skipped)"
2315
+ if (ui.questionIndex < questions.length - 1) {
2316
+ ui.questionIndex += 1
2317
+ ui.questionCustomInput = ""
2318
+ ui.questionCustomCursor = 0
2319
+ } else {
2320
+ resolveQuestionPrompt()
2321
+ }
2322
+ requestRender()
2323
+ return
2324
+ }
2325
+ if (key.name === "return") {
2326
+ ui.questionAnswers[currentQ.id] = ui.questionCustomInput || ""
2327
+ ui.questionCustomMode = false
2328
+ ui.questionCustomInput = ""
2329
+ ui.questionCustomCursor = 0
2330
+ if (ui.questionIndex < questions.length - 1) {
2331
+ ui.questionIndex += 1
2332
+ ui.questionOptionSelected = 0
2333
+ } else {
2334
+ resolveQuestionPrompt()
2335
+ }
2336
+ requestRender()
2337
+ return
2338
+ }
2339
+ if (key.name === "backspace") {
2340
+ if (ui.questionCustomCursor > 0) {
2341
+ const before = ui.questionCustomInput.slice(0, ui.questionCustomCursor - 1)
2342
+ const after = ui.questionCustomInput.slice(ui.questionCustomCursor)
2343
+ ui.questionCustomInput = before + after
2344
+ ui.questionCustomCursor -= 1
2345
+ }
2346
+ requestRender()
2347
+ return
2348
+ }
2349
+ if (key.name === "left") {
2350
+ ui.questionCustomCursor = Math.max(0, ui.questionCustomCursor - 1)
2351
+ requestRender()
2352
+ return
2353
+ }
2354
+ if (key.name === "right") {
2355
+ ui.questionCustomCursor = Math.min(ui.questionCustomInput.length, ui.questionCustomCursor + 1)
2356
+ requestRender()
2357
+ return
2358
+ }
2359
+ // Printable character
2360
+ if (str && !key.ctrl && !key.meta && str.length === 1 && str.charCodeAt(0) >= 32) {
2361
+ const before = ui.questionCustomInput.slice(0, ui.questionCustomCursor)
2362
+ const after = ui.questionCustomInput.slice(ui.questionCustomCursor)
2363
+ ui.questionCustomInput = before + str + after
2364
+ ui.questionCustomCursor += 1
2365
+ requestRender()
2366
+ return
2367
+ }
2368
+ return
2369
+ }
2370
+
2371
+ // Options mode
2372
+ if (key.name === "escape") {
2373
+ // Skip current question
2374
+ ui.questionAnswers[currentQ.id] = "(skipped)"
2375
+ if (ui.questionIndex < questions.length - 1) {
2376
+ ui.questionIndex += 1
2377
+ ui.questionOptionSelected = 0
2378
+ } else {
2379
+ resolveQuestionPrompt()
2380
+ }
2381
+ requestRender()
2382
+ return
2383
+ }
2384
+ if (key.name === "up") {
2385
+ ui.questionOptionSelected = Math.max(0, ui.questionOptionSelected - 1)
2386
+ requestRender()
2387
+ return
2388
+ }
2389
+ if (key.name === "down") {
2390
+ ui.questionOptionSelected = Math.min(maxOptIdx, ui.questionOptionSelected + 1)
2391
+ requestRender()
2392
+ return
2393
+ }
2394
+ if (key.name === "tab") {
2395
+ // Switch between questions
2396
+ if (key.shift) {
2397
+ ui.questionIndex = ui.questionIndex > 0 ? ui.questionIndex - 1 : questions.length - 1
2398
+ } else {
2399
+ ui.questionIndex = (ui.questionIndex + 1) % questions.length
2400
+ }
2401
+ ui.questionOptionSelected = 0
2402
+ ui.questionCustomMode = false
2403
+ requestRender()
2404
+ return
2405
+ }
2406
+ if (key.name === "space" && currentQ.multi) {
2407
+ // Toggle multi-select checkbox
2408
+ if (ui.questionOptionSelected < options.length) {
2409
+ if (!ui.questionMultiSelected[currentQ.id]) {
2410
+ ui.questionMultiSelected[currentQ.id] = new Set()
2411
+ }
2412
+ const set = ui.questionMultiSelected[currentQ.id]
2413
+ if (set.has(ui.questionOptionSelected)) {
2414
+ set.delete(ui.questionOptionSelected)
2415
+ } else {
2416
+ set.add(ui.questionOptionSelected)
2417
+ }
2418
+ requestRender()
2419
+ }
2420
+ return
2421
+ }
2422
+ if (key.name === "return") {
2423
+ // Custom... option selected
2424
+ if (ui.questionOptionSelected === options.length && currentQ.allowCustom !== false) {
2425
+ ui.questionCustomMode = true
2426
+ ui.questionCustomInput = ""
2427
+ ui.questionCustomCursor = 0
2428
+ requestRender()
2429
+ return
2430
+ }
2431
+ // Regular option selected
2432
+ advanceOrSubmitQuestion()
2433
+ return
2434
+ }
2435
+ return
2436
+ }
2437
+
2438
+ if (ui.modelPicker) {
2439
+ if (key.name === "escape") {
2440
+ closeModelPicker()
2441
+ return
2442
+ }
2443
+ if (key.name === "return") {
2444
+ confirmModelPicker()
2445
+ return
2446
+ }
2447
+ if (key.name === "up") {
2448
+ ui.modelPicker.selected = Math.max(0, ui.modelPicker.selected - 1)
2449
+ requestRender()
2450
+ return
2451
+ }
2452
+ if (key.name === "down") {
2453
+ ui.modelPicker.selected = Math.min(ui.modelPicker.items.length - 1, ui.modelPicker.selected + 1)
2454
+ requestRender()
2455
+ return
2456
+ }
2457
+ return
2458
+ }
2459
+
2460
+ if (ui.policyPicker) {
2461
+ if (key.name === "escape") {
2462
+ closePolicyPicker()
2463
+ return
2464
+ }
2465
+ if (key.name === "return") {
2466
+ confirmPolicyPicker()
2467
+ return
2468
+ }
2469
+ if (key.name === "up") {
2470
+ ui.policyPicker.selected = Math.max(0, ui.policyPicker.selected - 1)
2471
+ requestRender()
2472
+ return
2473
+ }
2474
+ if (key.name === "down") {
2475
+ ui.policyPicker.selected = Math.min(POLICY_CHOICES.length - 1, ui.policyPicker.selected + 1)
2476
+ requestRender()
2477
+ return
2478
+ }
2479
+ return
2480
+ }
2481
+
2482
+ // Scrolling keys work even when busy
2483
+ if (key.name === "pageup") {
2484
+ scrollBy(pageSize(ui.scrollMeta.logRows))
2485
+ requestRender()
2486
+ return
2487
+ }
2488
+
2489
+ if (key.name === "pagedown") {
2490
+ scrollBy(-pageSize(ui.scrollMeta.logRows))
2491
+ requestRender()
2492
+ return
2493
+ }
2494
+
2495
+ // Ctrl+Up/Down: scroll log area (3 lines at a time)
2496
+ if (key.ctrl && (key.name === "up" || key.name === "down")) {
2497
+ scrollBy(key.name === "up" ? 3 : -3)
2498
+ requestRender()
2499
+ return
2500
+ }
2501
+
2502
+ if (key.name === "home" && (key.ctrl || key.shift)) {
2503
+ scrollToTop()
2504
+ requestRender()
2505
+ return
2506
+ }
2507
+
2508
+ if (key.name === "end" && (key.ctrl || key.shift)) {
2509
+ scrollToBottom()
2510
+ requestRender()
2511
+ return
2512
+ }
2513
+
2514
+ if (ui.busy) return
2515
+
2516
+ // Ctrl+V: try image first, fall back to text paste
2517
+ if (key.ctrl && key.name === "v") {
2518
+ appendLog("reading clipboard...")
2519
+ requestRender()
2520
+ const clipBlock = await readClipboardImage({
2521
+ onStatus: (msg) => {
2522
+ if (msg) {
2523
+ // Update the last log line with status
2524
+ if (ui.logs.length && ui.logs[ui.logs.length - 1].startsWith("reading clipboard") || ui.logs[ui.logs.length - 1].startsWith("processing image")) {
2525
+ ui.logs[ui.logs.length - 1] = msg
2526
+ }
2527
+ }
2528
+ requestRender()
2529
+ }
2530
+ })
2531
+ // Remove status line
2532
+ if (ui.logs.length && (ui.logs[ui.logs.length - 1].startsWith("reading clipboard") || ui.logs[ui.logs.length - 1].startsWith("processing image"))) {
2533
+ ui.logs.pop()
2534
+ }
2535
+ if (clipBlock && clipBlock.type === "image") {
2536
+ ui.pendingImages.push(clipBlock)
2537
+ appendLog(`image pasted (${ui.pendingImages.length} attached)`)
2538
+ requestRender()
2539
+ return
2540
+ }
2541
+ if (clipBlock && clipBlock.type === "error") {
2542
+ appendLog(`paste failed: ${clipBlock.message}`)
2543
+ requestRender()
2544
+ return
2545
+ }
2546
+ // No image — try text clipboard
2547
+ const clipText = await readClipboardText()
2548
+ if (clipText) {
2549
+ insertAtCursor(clipText)
2550
+ }
2551
+ requestRender()
2552
+ return
2553
+ }
2554
+
2555
+ if (key.name === "return") {
2556
+ if (key.shift) {
2557
+ insertAtCursor("\n")
2558
+ requestRender()
2559
+ return
2560
+ }
2561
+ if (shouldApplySuggestionOnEnter()) {
2562
+ applyCurrentSuggestion()
2563
+ ui.selectedSuggestion = 0
2564
+ ui.suggestionOffset = 0
2565
+ requestRender()
2566
+ return
2567
+ }
2568
+ await submitCurrentInput()
2569
+ if (ui.quitting) finish()
2570
+ return
2571
+ }
2572
+
2573
+ if (key.ctrl && key.name === "j") {
2574
+ insertAtCursor("\n")
2575
+ requestRender()
2576
+ return
2577
+ }
2578
+
2579
+ if (key.name === "backspace") {
2580
+ if (ui.inputCursor > 0) {
2581
+ const head = ui.input.slice(0, ui.inputCursor - 1)
2582
+ const tail = ui.input.slice(ui.inputCursor)
2583
+ ui.input = `${head}${tail}`
2584
+ ui.inputCursor -= 1
2585
+ }
2586
+ ui.selectedSuggestion = 0
2587
+ ui.suggestionOffset = 0
2588
+ requestRender()
2589
+ return
2590
+ }
2591
+
2592
+ if (key.name === "delete") {
2593
+ const head = ui.input.slice(0, ui.inputCursor)
2594
+ const tail = ui.input.slice(ui.inputCursor + 1)
2595
+ ui.input = `${head}${tail}`
2596
+ ui.selectedSuggestion = 0
2597
+ ui.suggestionOffset = 0
2598
+ requestRender()
2599
+ return
2600
+ }
2601
+
2602
+ if (key.name === "escape") {
2603
+ ui.input = ""
2604
+ ui.inputCursor = 0
2605
+ ui.selectedSuggestion = 0
2606
+ ui.suggestionOffset = 0
2607
+ requestRender()
2608
+ return
2609
+ }
2610
+
2611
+ if (key.name === "tab") {
2612
+ cycleModeForwardAndNotify()
2613
+ return
2614
+ }
2615
+
2616
+ if (key.name === "left") {
2617
+ moveCursor(-1)
2618
+ requestRender()
2619
+ return
2620
+ }
2621
+
2622
+ if (key.name === "right") {
2623
+ moveCursor(1)
2624
+ requestRender()
2625
+ return
2626
+ }
2627
+
2628
+ if (key.name === "home") {
2629
+ if (key.ctrl || key.shift) {
2630
+ // Ctrl+Home or Shift+Home: scroll to top of logs
2631
+ scrollToTop()
2632
+ requestRender()
2633
+ } else {
2634
+ // Home: move input cursor to start
2635
+ setCursor(0)
2636
+ requestRender()
2637
+ }
2638
+ return
2639
+ }
2640
+
2641
+ if (key.name === "end") {
2642
+ if (key.ctrl || key.shift) {
2643
+ // Ctrl+End or Shift+End: scroll to bottom of logs
2644
+ scrollToBottom()
2645
+ requestRender()
2646
+ } else {
2647
+ // End: move input cursor to end
2648
+ setCursor(ui.input.length)
2649
+ requestRender()
2650
+ }
2651
+ return
2652
+ }
2653
+
2654
+ if (key.name === "up" || key.name === "down") {
2655
+ const handled = handleUpDownSuggestions(key.name)
2656
+ if (!handled) navigateHistory(key.name)
2657
+ requestRender()
2658
+ return
2659
+ }
2660
+
2661
+ if (key.ctrl && key.name === "l" && !key.shift) {
2662
+ ui.logs = []
2663
+ requestRender()
2664
+ return
2665
+ }
2666
+
2667
+ if (typeof str === "string" && str.length > 0 && !key.ctrl && !key.meta) {
2668
+ insertAtCursor(str)
2669
+ ui.selectedSuggestion = 0
2670
+ ui.suggestionOffset = 0
2671
+ requestRender()
2672
+ }
2673
+ }
2674
+ onData = async (chunk) => {
2675
+ if (ui.quitting) return
2676
+ if (ui.busy) return
2677
+ if (!hasShiftEnterSequence(chunk)) return
2678
+ insertAtCursor("\n")
2679
+ requestRender()
2680
+ }
2681
+ onSigint = () => {
2682
+ ui.quitting = true
2683
+ finish()
2684
+ }
2685
+
2686
+ process.stdout.on("resize", onResize)
2687
+ process.stdin.on("keypress", onKey)
2688
+ process.stdin.on("data", onData)
2689
+ process.on("SIGINT", onSigint)
2690
+ })
2691
+ } finally {
2692
+ if (renderTimer) clearTimeout(renderTimer)
2693
+ stopBusySpinner()
2694
+ activityRenderer.stop()
2695
+ uiEventUnsub()
2696
+ setPermissionPromptHandler(null)
2697
+ setQuestionPromptHandler(null)
2698
+ if (onResize) process.stdout.removeListener("resize", onResize)
2699
+ if (onKey) process.stdin.removeListener("keypress", onKey)
2700
+ if (onData) process.stdin.removeListener("data", onData)
2701
+ if (onSigint) process.removeListener("SIGINT", onSigint)
2702
+ if (process.stdin.isTTY) process.stdin.setRawMode(false)
2703
+ stopTuiFrame()
2704
+ await saveHistoryLines(ui.history)
2705
+ }
2706
+ }
2707
+
2708
+ function startSplash() {
2709
+ if (!process.stdout.isTTY) return { update() {}, stop() {} }
2710
+
2711
+ const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
2712
+
2713
+ // Block-style logo — each character colored individually for wave effect
2714
+ const logo = [
2715
+ " ██╗ ██╗ ██╗ ██╗ ██████╗ ██████╗ ██████╗ ███████╗ ",
2716
+ " ██║ ██╔╝ ██║ ██╔╝ ██╔════╝ ██╔═══██╗ ██╔══██╗ ██╔════╝ ",
2717
+ " █████╔╝ █████╔╝ ██║ ██║ ██║ ██║ ██║ █████╗ ",
2718
+ " ██╔═██╗ ██╔═██╗ ██║ ██║ ██║ ██║ ██║ ██╔══╝ ",
2719
+ " ██║ ██╗ ██║ ██╗ ╚██████╗ ╚██████╔╝ ██████╔╝ ███████╗ ",
2720
+ " ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝ "
2721
+ ]
2722
+ const tagline = "AI Coding Agent"
2723
+ const version = "v0.1.2"
2724
+
2725
+ // Gradient colors for the wave animation (cyan → blue → purple → pink → back)
2726
+ const wave = [
2727
+ "#4af5f0", "#3de8f5", "#30dbfa", "#38c8ff", "#40b5ff",
2728
+ "#58a0ff", "#708bff", "#8876ff", "#a061ff", "#b84cff",
2729
+ "#d037ff", "#e828f0", "#f034d0", "#f040b0", "#f04c90",
2730
+ "#f040b0", "#f034d0", "#e828f0", "#d037ff", "#b84cff",
2731
+ "#a061ff", "#8876ff", "#708bff", "#58a0ff", "#40b5ff",
2732
+ "#38c8ff", "#30dbfa", "#3de8f5"
2733
+ ]
2734
+
2735
+ let tick = 0
2736
+ let status = "loading config..."
2737
+ let steps = []
2738
+ let revealChars = 0 // typewriter reveal counter
2739
+ const totalChars = logo[0].length
2740
+ const revealSpeed = 3 // chars revealed per tick
2741
+
2742
+ // Paint a single character with a hex color using raw ANSI (avoid overhead of paint())
2743
+ function charColor(ch, hex) {
2744
+ if (ch === " " || ch === "\n") return ch
2745
+ const r = parseInt(hex.slice(1, 3), 16)
2746
+ const g = parseInt(hex.slice(3, 5), 16)
2747
+ const b = parseInt(hex.slice(5, 7), 16)
2748
+ return `\x1b[1;38;2;${r};${g};${b}m${ch}\x1b[0m`
2749
+ }
2750
+
2751
+ function render() {
2752
+ const cols = process.stdout.columns || 80
2753
+ const rows = process.stdout.rows || 24
2754
+ const lines = []
2755
+
2756
+ const contentHeight = logo.length + 4 + steps.length + 2
2757
+ const topPad = Math.max(0, Math.floor((rows - contentHeight) / 2))
2758
+ for (let i = 0; i < topPad; i++) lines.push("")
2759
+
2760
+ // Render logo with color wave + typewriter reveal
2761
+ const visible = Math.min(revealChars, totalChars)
2762
+ for (let row = 0; row < logo.length; row++) {
2763
+ const line = logo[row]
2764
+ const pad = Math.max(0, Math.floor((cols - line.length) / 2))
2765
+ let out = " ".repeat(pad)
2766
+ for (let col = 0; col < line.length; col++) {
2767
+ if (col >= visible) { out += " "; continue }
2768
+ const ch = line[col]
2769
+ // Wave: color index based on column + time offset, different phase per row
2770
+ const waveIdx = (col + tick * 2 + row * 3) % wave.length
2771
+ out += charColor(ch, wave[waveIdx])
2772
+ }
2773
+ lines.push(out)
2774
+ }
2775
+
2776
+ // Tagline + version (fade in after logo is revealed)
2777
+ const tagFull = `${tagline} · ${version}`
2778
+ if (visible >= totalChars) {
2779
+ const tagPad = Math.max(0, Math.floor((cols - tagFull.length) / 2))
2780
+ const tagAlpha = Math.min(1, (revealChars - totalChars) / 20)
2781
+ // Interpolate from dim to bright
2782
+ const brightness = Math.round(100 + 155 * tagAlpha)
2783
+ const tagHex = `#${brightness.toString(16).padStart(2, "0")}${brightness.toString(16).padStart(2, "0")}${brightness.toString(16).padStart(2, "0")}`
2784
+ lines.push(" ".repeat(tagPad) + paint(tagFull, tagHex, { dim: tagAlpha < 0.5 }))
2785
+ } else {
2786
+ lines.push("")
2787
+ }
2788
+
2789
+ // Separator line — subtle gradient bar
2790
+ if (visible >= totalChars) {
2791
+ const barWidth = Math.min(40, cols - 4)
2792
+ const barPad = Math.max(0, Math.floor((cols - barWidth) / 2))
2793
+ let bar = ""
2794
+ for (let i = 0; i < barWidth; i++) {
2795
+ const ci = (i + tick) % wave.length
2796
+ bar += charColor("─", wave[ci])
2797
+ }
2798
+ lines.push(" ".repeat(barPad) + bar)
2799
+ } else {
2800
+ lines.push("")
2801
+ }
2802
+
2803
+ lines.push("")
2804
+
2805
+ // Completed steps
2806
+ for (const s of steps) {
2807
+ const pad = Math.max(0, Math.floor((cols - s.length - 4) / 2))
2808
+ lines.push(" ".repeat(pad) + paint(` ✓ ${s}`, "#3fd487"))
2809
+ }
2810
+
2811
+ // Current spinner
2812
+ const spinChar = frames[tick % frames.length]
2813
+ const spinLine = `${spinChar} ${status}`
2814
+ const spinPad = Math.max(0, Math.floor((cols - spinLine.length - 2) / 2))
2815
+ lines.push(" ".repeat(spinPad) + paint(` ${spinLine}`, "#6ec1ff", { bold: true }))
2816
+
2817
+ process.stdout.write("\x1B[?25l")
2818
+ process.stdout.write("\x1Bc")
2819
+ process.stdout.write(lines.join("\n"))
2820
+ }
2821
+
2822
+ render()
2823
+ const timer = setInterval(() => {
2824
+ tick++
2825
+ if (revealChars < totalChars + 30) revealChars += revealSpeed
2826
+ render()
2827
+ }, 50)
2828
+
2829
+ return {
2830
+ update(text) {
2831
+ steps.push(status.replace("...", ""))
2832
+ status = text
2833
+ render()
2834
+ },
2835
+ stop() {
2836
+ clearInterval(timer)
2837
+ process.stdout.write("\x1B[?25h")
2838
+ process.stdout.write("\x1Bc")
2839
+ }
2840
+ }
2841
+ }
2842
+
2843
+ export async function startRepl({ trust = false } = {}) {
2844
+ // Trust check BEFORE splash — readline prompt must not compete with splash screen clearing
2845
+ const { checkWorkspaceTrust } = await import("./permission/workspace-trust.mjs")
2846
+ const trustState = await checkWorkspaceTrust({ cwd: process.cwd(), cliTrust: trust, isTTY: process.stdin.isTTY })
2847
+
2848
+ const splash = startSplash()
2849
+
2850
+ const ctx = await buildContext({ trust, trustState })
2851
+ printContextWarnings(ctx)
2852
+
2853
+ splash.update("loading tools & MCP servers...")
2854
+ await ToolRegistry.initialize({ config: ctx.configState.config, cwd: process.cwd() })
2855
+
2856
+ // Collect MCP status for later display
2857
+ const mcpHealth = McpRegistry.healthSnapshot()
2858
+ const mcpStatusLines = []
2859
+ for (const entry of mcpHealth) {
2860
+ if (entry.ok) {
2861
+ const toolCount = McpRegistry.listTools().filter((t) => t.server === entry.name).length
2862
+ mcpStatusLines.push(paint(` mcp ✓ ${entry.name}`, ctx.themeState.theme.semantic.success) + paint(` (${toolCount} tools, ${entry.transport})`, ctx.themeState.theme.base.muted))
2863
+ } else {
2864
+ const reason = entry.error || entry.reason || "unknown"
2865
+ mcpStatusLines.push(paint(` mcp ✗ ${entry.name}`, ctx.themeState.theme.semantic.error) + paint(` ${reason}`, ctx.themeState.theme.base.muted))
2866
+ }
2867
+ }
2868
+
2869
+ splash.update("loading skills & agents...")
2870
+ await SkillRegistry.initialize(ctx.configState.config, process.cwd())
2871
+ const { CustomAgentRegistry } = await import("./agent/custom-agent-loader.mjs")
2872
+ await CustomAgentRegistry.initialize(process.cwd())
2873
+
2874
+ splash.update("loading hooks & history...")
2875
+ await initHookBus()
2876
+ const historyLines = await loadHistory()
2877
+
2878
+ splash.update("preparing workspace...")
2879
+ const state = {
2880
+ sessionId: newSessionId(),
2881
+ mode: ctx.configState.config.agent.default_mode || "agent",
2882
+ providerType: ctx.configState.config.provider.default,
2883
+ model: ""
2884
+ }
2885
+ state.model = resolveProviderDefaultModel(ctx.configState.config, state.providerType)
2886
+
2887
+ // Check if auto memory file exists
2888
+ try {
2889
+ await readFile(memoryFilePath(process.cwd()), "utf8")
2890
+ state.memoryLoaded = true
2891
+ } catch {
2892
+ state.memoryLoaded = false
2893
+ }
2894
+
2895
+ const customCommands = await loadCustomCommands(process.cwd())
2896
+ const providersConfigured = configuredProviders(ctx.configState.config)
2897
+ const recentSessions = await listSessions({ cwd: process.cwd(), limit: 6, includeChildren: false }).catch(() => [])
2898
+
2899
+ splash.stop()
2900
+
2901
+ PermissionEngine.setTrusted(ctx.trustState?.trusted !== false)
2902
+ if (!ctx.trustState?.trusted) {
2903
+ console.log(paint(" ⚠ workspace not trusted — tools are blocked. Run /trust to enable.", ctx.themeState.theme.semantic.warning))
2904
+ }
2905
+
2906
+ if (process.stdout.isTTY && process.stdin.isTTY) {
2907
+ await startTuiRepl({
2908
+ ctx,
2909
+ state,
2910
+ providersConfigured,
2911
+ customCommands,
2912
+ recentSessions,
2913
+ historyLines,
2914
+ mcpStatusLines
2915
+ })
2916
+ return
2917
+ }
2918
+
2919
+ clearScreen()
2920
+ for (const line of mcpStatusLines) console.log(line)
2921
+ await startLineRepl({
2922
+ ctx,
2923
+ state,
2924
+ providersConfigured,
2925
+ customCommands,
2926
+ recentSessions,
2927
+ historyLines
2928
+ })
2929
+ }