@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.
- package/LICENSE +674 -0
- package/README.md +445 -0
- package/package.json +46 -0
- package/src/agent/agent.mjs +170 -0
- package/src/agent/custom-agent-loader.mjs +158 -0
- package/src/agent/generator.mjs +115 -0
- package/src/agent/prompt/architect.txt +36 -0
- package/src/agent/prompt/build-fixer.txt +71 -0
- package/src/agent/prompt/build.txt +101 -0
- package/src/agent/prompt/compaction.txt +12 -0
- package/src/agent/prompt/explore.txt +29 -0
- package/src/agent/prompt/guide.txt +40 -0
- package/src/agent/prompt/longagent.txt +178 -0
- package/src/agent/prompt/plan.txt +50 -0
- package/src/agent/prompt/researcher.txt +23 -0
- package/src/agent/prompt/reviewer.txt +44 -0
- package/src/agent/prompt/security-reviewer.txt +62 -0
- package/src/agent/prompt/tdd-guide.txt +84 -0
- package/src/agent/prompt/title.txt +8 -0
- package/src/command/custom-commands.mjs +57 -0
- package/src/commands/agent.mjs +71 -0
- package/src/commands/audit.mjs +77 -0
- package/src/commands/background.mjs +86 -0
- package/src/commands/chat.mjs +114 -0
- package/src/commands/command.mjs +41 -0
- package/src/commands/config.mjs +44 -0
- package/src/commands/doctor.mjs +148 -0
- package/src/commands/hook.mjs +29 -0
- package/src/commands/init.mjs +141 -0
- package/src/commands/longagent.mjs +100 -0
- package/src/commands/mcp.mjs +89 -0
- package/src/commands/permission.mjs +36 -0
- package/src/commands/prompt.mjs +42 -0
- package/src/commands/review.mjs +266 -0
- package/src/commands/rule.mjs +34 -0
- package/src/commands/session.mjs +235 -0
- package/src/commands/theme.mjs +98 -0
- package/src/commands/usage.mjs +91 -0
- package/src/config/defaults.mjs +195 -0
- package/src/config/import-config.mjs +76 -0
- package/src/config/load-config.mjs +76 -0
- package/src/config/schema.mjs +509 -0
- package/src/context.mjs +40 -0
- package/src/core/constants.mjs +46 -0
- package/src/core/errors.mjs +57 -0
- package/src/core/events.mjs +29 -0
- package/src/core/types.mjs +57 -0
- package/src/github/api.mjs +78 -0
- package/src/github/auth.mjs +286 -0
- package/src/github/flow.mjs +298 -0
- package/src/github/workspace.mjs +212 -0
- package/src/index.mjs +82 -0
- package/src/knowledge/api-design.txt +9 -0
- package/src/knowledge/cpp.txt +10 -0
- package/src/knowledge/docker.txt +10 -0
- package/src/knowledge/dotnet.txt +9 -0
- package/src/knowledge/electron.txt +10 -0
- package/src/knowledge/flutter.txt +10 -0
- package/src/knowledge/go.txt +9 -0
- package/src/knowledge/graphql.txt +10 -0
- package/src/knowledge/java.txt +9 -0
- package/src/knowledge/kotlin.txt +10 -0
- package/src/knowledge/loader.mjs +125 -0
- package/src/knowledge/next.txt +8 -0
- package/src/knowledge/node.txt +8 -0
- package/src/knowledge/nuxt.txt +9 -0
- package/src/knowledge/php.txt +10 -0
- package/src/knowledge/python.txt +10 -0
- package/src/knowledge/react-native.txt +10 -0
- package/src/knowledge/react.txt +9 -0
- package/src/knowledge/ruby.txt +11 -0
- package/src/knowledge/rust.txt +9 -0
- package/src/knowledge/svelte.txt +9 -0
- package/src/knowledge/swift.txt +10 -0
- package/src/knowledge/tailwind.txt +10 -0
- package/src/knowledge/testing.txt +8 -0
- package/src/knowledge/typescript.txt +8 -0
- package/src/knowledge/vue.txt +9 -0
- package/src/mcp/client-http.mjs +157 -0
- package/src/mcp/client-sse.mjs +286 -0
- package/src/mcp/client-stdio.mjs +451 -0
- package/src/mcp/registry.mjs +394 -0
- package/src/mcp/stdio-framing.mjs +127 -0
- package/src/orchestration/background-manager.mjs +358 -0
- package/src/orchestration/background-worker.mjs +245 -0
- package/src/orchestration/longagent-manager.mjs +116 -0
- package/src/orchestration/stage-scheduler.mjs +489 -0
- package/src/orchestration/subagent-router.mjs +62 -0
- package/src/orchestration/task-scheduler.mjs +74 -0
- package/src/permission/engine.mjs +92 -0
- package/src/permission/exec-policy.mjs +372 -0
- package/src/permission/prompt.mjs +39 -0
- package/src/permission/rules.mjs +120 -0
- package/src/permission/workspace-trust.mjs +44 -0
- package/src/plugin/builtin-hooks/console-warn.mjs +41 -0
- package/src/plugin/builtin-hooks/extract-patterns.mjs +75 -0
- package/src/plugin/builtin-hooks/post-edit-format.mjs +57 -0
- package/src/plugin/builtin-hooks/post-edit-typecheck.mjs +61 -0
- package/src/plugin/builtin-hooks/strategic-compaction.mjs +38 -0
- package/src/plugin/hook-bus.mjs +154 -0
- package/src/provider/anthropic.mjs +389 -0
- package/src/provider/ollama.mjs +236 -0
- package/src/provider/openai-compatible.mjs +1 -0
- package/src/provider/openai.mjs +339 -0
- package/src/provider/retry-policy.mjs +68 -0
- package/src/provider/router.mjs +228 -0
- package/src/provider/sse.mjs +91 -0
- package/src/repl.mjs +2929 -0
- package/src/review/diff-parser.mjs +36 -0
- package/src/review/rejection-queue.mjs +62 -0
- package/src/review/review-store.mjs +21 -0
- package/src/review/risk-score.mjs +61 -0
- package/src/rules/load-rules.mjs +64 -0
- package/src/runtime.mjs +1 -0
- package/src/session/checkpoint.mjs +239 -0
- package/src/session/compaction.mjs +276 -0
- package/src/session/engine.mjs +225 -0
- package/src/session/instinct-manager.mjs +172 -0
- package/src/session/instruction-loader.mjs +25 -0
- package/src/session/longagent-plan.mjs +329 -0
- package/src/session/longagent-scaffold.mjs +100 -0
- package/src/session/longagent.mjs +1462 -0
- package/src/session/loop.mjs +905 -0
- package/src/session/memory-loader.mjs +75 -0
- package/src/session/project-context.mjs +367 -0
- package/src/session/prompt/anthropic.txt +151 -0
- package/src/session/prompt/beast.txt +37 -0
- package/src/session/prompt/max-steps.txt +6 -0
- package/src/session/prompt/plan.txt +9 -0
- package/src/session/prompt/qwen.txt +46 -0
- package/src/session/prompt-loader.mjs +18 -0
- package/src/session/recovery.mjs +52 -0
- package/src/session/store.mjs +503 -0
- package/src/session/system-prompt.mjs +260 -0
- package/src/session/task-validator.mjs +266 -0
- package/src/session/usability-gates.mjs +379 -0
- package/src/skill/builtin/backend-patterns.mjs +123 -0
- package/src/skill/builtin/commit.mjs +64 -0
- package/src/skill/builtin/debug.mjs +45 -0
- package/src/skill/builtin/frontend-patterns.mjs +120 -0
- package/src/skill/builtin/frontend.mjs +188 -0
- package/src/skill/builtin/init.mjs +220 -0
- package/src/skill/builtin/review.mjs +49 -0
- package/src/skill/builtin/security-checklist.mjs +80 -0
- package/src/skill/builtin/tdd.mjs +54 -0
- package/src/skill/generator.mjs +113 -0
- package/src/skill/registry.mjs +336 -0
- package/src/storage/audit-store.mjs +83 -0
- package/src/storage/event-log.mjs +82 -0
- package/src/storage/ghost-commit-store.mjs +235 -0
- package/src/storage/json-store.mjs +53 -0
- package/src/storage/paths.mjs +148 -0
- package/src/theme/color.mjs +64 -0
- package/src/theme/default-theme.mjs +29 -0
- package/src/theme/load-theme.mjs +71 -0
- package/src/theme/markdown.mjs +135 -0
- package/src/theme/schema.mjs +45 -0
- package/src/theme/status-bar.mjs +158 -0
- package/src/tool/audit-wrapper.mjs +38 -0
- package/src/tool/edit-transaction.mjs +126 -0
- package/src/tool/executor.mjs +109 -0
- package/src/tool/file-lock-manager.mjs +85 -0
- package/src/tool/git-auto.mjs +545 -0
- package/src/tool/git-full-auto.mjs +478 -0
- package/src/tool/image-util.mjs +276 -0
- package/src/tool/prompt/background_cancel.txt +1 -0
- package/src/tool/prompt/background_output.txt +1 -0
- package/src/tool/prompt/bash.txt +71 -0
- package/src/tool/prompt/codesearch.txt +18 -0
- package/src/tool/prompt/edit.txt +27 -0
- package/src/tool/prompt/enter_plan.txt +74 -0
- package/src/tool/prompt/exit_plan.txt +62 -0
- package/src/tool/prompt/glob.txt +33 -0
- package/src/tool/prompt/grep.txt +43 -0
- package/src/tool/prompt/list.txt +8 -0
- package/src/tool/prompt/multiedit.txt +20 -0
- package/src/tool/prompt/notebookedit.txt +21 -0
- package/src/tool/prompt/patch.txt +24 -0
- package/src/tool/prompt/question.txt +44 -0
- package/src/tool/prompt/read.txt +40 -0
- package/src/tool/prompt/task.txt +83 -0
- package/src/tool/prompt/todowrite.txt +117 -0
- package/src/tool/prompt/webfetch.txt +38 -0
- package/src/tool/prompt/websearch.txt +43 -0
- package/src/tool/prompt/write.txt +38 -0
- package/src/tool/prompt-loader.mjs +18 -0
- package/src/tool/question-prompt.mjs +86 -0
- package/src/tool/registry.mjs +1309 -0
- package/src/tool/task-tool.mjs +28 -0
- package/src/ui/activity-renderer.mjs +410 -0
- package/src/ui/repl-dashboard.mjs +357 -0
- package/src/usage/pricing.mjs +121 -0
- package/src/usage/usage-meter.mjs +113 -0
- package/src/util/git.mjs +496 -0
- package/src/util/template.mjs +10 -0
- 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
|
+
}
|