heartcode 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +129 -2
- package/SKILL.md +170 -0
- package/bin/heart.js +77 -0
- package/bin/heartcode.js +1573 -0
- package/package.json +15 -4
- package/prompts/README.md +30 -0
- package/prompts/agent.md +320 -0
- package/prompts/compact.md +95 -0
- package/prompts/guided.md +193 -0
- package/prompts/probe.md +67 -0
- package/src/acp.js +248 -0
- package/src/agent.js +642 -0
- package/src/client.js +545 -0
- package/src/config.js +105 -0
- package/src/context.js +91 -0
- package/src/director.js +143 -0
- package/src/edit.js +127 -0
- package/src/format.js +74 -0
- package/src/guided.js +609 -0
- package/src/headless.js +217 -0
- package/src/hooks.js +80 -0
- package/src/importer.js +202 -0
- package/src/log.js +139 -0
- package/src/lsp.js +207 -0
- package/src/mcp.js +330 -0
- package/src/memory.js +222 -0
- package/src/oauth.js +122 -0
- package/src/patch.js +96 -0
- package/src/probe.js +343 -0
- package/src/prompts.js +103 -0
- package/src/reset.js +49 -0
- package/src/sessions.js +135 -0
- package/src/settings.html +1540 -0
- package/src/settings.js +526 -0
- package/src/skills.js +71 -0
- package/src/tabs.js +147 -0
- package/src/tools.js +774 -0
- package/src/tree.js +162 -0
- package/src/ui.js +1278 -0
- package/src/usage.js +134 -0
- package/src/voice.js +97 -0
- package/src/web.js +123 -0
- package/src/worktree.js +56 -0
package/bin/heartcode.js
ADDED
|
@@ -0,0 +1,1573 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { parseArgs } from 'node:util'
|
|
4
|
+
import { Client } from '../src/client.js'
|
|
5
|
+
import { loadConfig, saveConfig, readJson, writeJson, providerName, CONFIG_FILE, HOME, DEFAULT_MODEL } from '../src/config.js'
|
|
6
|
+
import { probe, loadProfile, strategy, describe, profilePath, profileFrom } from '../src/probe.js'
|
|
7
|
+
import { learnFromUsage, RATE_CAP_MS } from '../src/context.js'
|
|
8
|
+
import { Session, transcript } from '../src/agent.js'
|
|
9
|
+
import { treeItems, codePlan, move, leaving, leftText, leftFiles } from '../src/tree.js'
|
|
10
|
+
import { UI, c, PERMISSIONS, rank, setTheme, themeCodes, renderDiff, diffLines, DEFAULT_KEYS, loadKeys } from '../src/ui.js'
|
|
11
|
+
import { listFiles, runCommand, clip } from '../src/tools.js'
|
|
12
|
+
import { log, LOG_FILE, LOG_DIR, requestLog, useRequestLog, useLogs, pruneLogs, logDays } from '../src/log.js'
|
|
13
|
+
import { prompt, usePromptsFrom, reloadPrompts } from '../src/prompts.js'
|
|
14
|
+
import { newChat, saveChat, listChats, loadChat, restoreFiles, ago, forkChat, exportChat } from '../src/sessions.js'
|
|
15
|
+
import { loadPrices, priceOf, addUsage, money, limitsOf } from '../src/usage.js'
|
|
16
|
+
import { Mcp } from '../src/mcp.js'
|
|
17
|
+
import { forget } from '../src/oauth.js'
|
|
18
|
+
import { loadSkills, fillArgs, extraSkillDirs } from '../src/skills.js'
|
|
19
|
+
import { HeadlessUI, runHeadless, toolNames, PERMISSION_MODES } from '../src/headless.js'
|
|
20
|
+
import { findImports } from '../src/importer.js'
|
|
21
|
+
import { spawnSync } from 'node:child_process'
|
|
22
|
+
import fs from 'node:fs'
|
|
23
|
+
import { Memory } from '../src/memory.js'
|
|
24
|
+
import { Director } from '../src/director.js'
|
|
25
|
+
import { createWorktree, worktreeState, removeWorktree } from '../src/worktree.js'
|
|
26
|
+
import { runAcp } from '../src/acp.js'
|
|
27
|
+
import { Tabs } from '../src/tabs.js'
|
|
28
|
+
import { startSettings } from '../src/settings.js'
|
|
29
|
+
import { RESETS, reset, resetPaths, resetCode } from '../src/reset.js'
|
|
30
|
+
|
|
31
|
+
const VERSION = '0.1.0'
|
|
32
|
+
const LOCAL_SERVERS = ['http://localhost:11434/v1', 'http://localhost:1234/v1', 'http://localhost:8080/v1']
|
|
33
|
+
|
|
34
|
+
const HELP = `${c.magenta('♥')} ${c.bold('HeartCode')} — a coding CLI for any local or OpenAI-compatible model
|
|
35
|
+
|
|
36
|
+
${c.bold('usage')}
|
|
37
|
+
heart interactive session in this folder (or: heart .)
|
|
38
|
+
heart ~/code/my-app in that folder (a folder, not a file)
|
|
39
|
+
heart "fix the failing test" run one task and exit
|
|
40
|
+
heart providers list added providers
|
|
41
|
+
heart models [filter] "default" (your list), then your models: #1 (in use), then its fallbacks
|
|
42
|
+
heart models --all [filter] every provider's models, yours marked
|
|
43
|
+
heart models --json as JSON (with --all too), for other programs
|
|
44
|
+
heart probe test the current model for real (otherwise its abilities come from models.dev)
|
|
45
|
+
heart config show the config file
|
|
46
|
+
heart settings change providers, models and settings in your browser
|
|
47
|
+
|
|
48
|
+
${c.bold('options')}
|
|
49
|
+
-u, --base-url <url> use this server for this run (add --save to keep it)
|
|
50
|
+
-m, --model <name> use this model alone for this run (no fallbacks); "default": your list, with its fallbacks
|
|
51
|
+
--api-key <key> API key for --base-url
|
|
52
|
+
-y, --yes do everything without asking (the default)
|
|
53
|
+
--ask ask before every edit and command, this run
|
|
54
|
+
--hide-thinking collapse the model's thinking to one line
|
|
55
|
+
-C, --cwd <dir> project folder (default: current folder)
|
|
56
|
+
--temp a throwaway settings folder for this run (your providers, model tests
|
|
57
|
+
and prices copied in; deleted after); ~/.heart isn't touched
|
|
58
|
+
--context <n> context window size in tokens
|
|
59
|
+
--force re-run the probe
|
|
60
|
+
--save keep the given -u/-m/--api-key/... in ${CONFIG_FILE}
|
|
61
|
+
-w, --worktree work in a new git worktree and branch (--worktree=name to name it,
|
|
62
|
+
or to continue in it); removed on exit if nothing changed
|
|
63
|
+
-c, --continue continue the last session in this folder
|
|
64
|
+
-r, --resume [id] pick an earlier session to continue (or the one with this id)
|
|
65
|
+
--provider <name> use this provider for this run (or -m "provider model")
|
|
66
|
+
|
|
67
|
+
${c.bold('headless')} (for other programs; flags and output as in Claude Code's -p)
|
|
68
|
+
-p, --print run without the screen, print the answer, exit
|
|
69
|
+
--output-format <f> text (default), json, or stream-json (one JSON event per line)
|
|
70
|
+
--input-format <f> text (default; the prompt, or stdin) or stream-json (messages on stdin)
|
|
71
|
+
--append-system-prompt <text> added to HeartCode's system prompt
|
|
72
|
+
--mcp-config <file|json> more MCP servers (repeatable); --strict-mcp-config: only those
|
|
73
|
+
--skills-dir <dir> more skills (repeatable)
|
|
74
|
+
--allowedTools <list> only these tools (HeartCode's or Claude Code's names: Read, Edit, Bash…)
|
|
75
|
+
--disallowedTools <list> never these tools
|
|
76
|
+
--permission-mode <m> all, commands, ask, plan (or bypassPermissions, acceptEdits, default);
|
|
77
|
+
--dangerously-skip-permissions = all. Nothing is asked: the rest is declined
|
|
78
|
+
--session-id <id> start (or continue) the session with this id
|
|
79
|
+
--effort <level> low, medium or high, for thinking models
|
|
80
|
+
--no-memory don't read or write memory notes
|
|
81
|
+
--max-retries <n> give up on a failing request after n retries (headless default 10;
|
|
82
|
+
interactive: until Esc)
|
|
83
|
+
--max-turns <n> stop after n model steps in one message (default: no limit)
|
|
84
|
+
--verbose warnings and retries on stderr
|
|
85
|
+
|
|
86
|
+
${c.bold('editors')} heartcode --acp the Agent Client Protocol on stdin/stdout (Zed, JetBrains, Neovim)
|
|
87
|
+
|
|
88
|
+
${c.bold('env')} HEART_BASE_URL, HEART_MODEL, HEART_API_KEY (or OPENAI_BASE_URL / OPENAI_API_KEY)
|
|
89
|
+
HEART_LOG=<file> where heart logs everything it does (default: ~/.heart/logs/, one per run; off: none)
|
|
90
|
+
|
|
91
|
+
${c.bold('in a session')} attach an image with @path/to/shot.png · /help for commands`
|
|
92
|
+
|
|
93
|
+
// [name, description, takes an argument], by section. /help and the "/" menu are built from it.
|
|
94
|
+
const SECTIONS = [
|
|
95
|
+
['providers and models', [
|
|
96
|
+
['/select_model', 'switch to another of your models (the list, and every provider\'s models: /settings)', true],
|
|
97
|
+
]],
|
|
98
|
+
['settings', [
|
|
99
|
+
['/toggle_thinking', "show or collapse the model's thinking"],
|
|
100
|
+
['/voice', 'say your message (or press Ctrl+R in the input): it goes in the input box'],
|
|
101
|
+
]],
|
|
102
|
+
['session', [
|
|
103
|
+
['/resume', 'continue an earlier session in this folder'],
|
|
104
|
+
['/rewind', 'go back to an earlier message: conversation, code or both (Esc Esc); what came after stays in /tree'],
|
|
105
|
+
['/tree', "every message on every branch of this session: go to any, the files follow, a summary of what you leave comes along"],
|
|
106
|
+
['/rename', 'name this session (shown in /resume)', true],
|
|
107
|
+
['/fork', 'go on in a copy of this session (the original stays in /resume)'],
|
|
108
|
+
['/export', 'save the conversation as Markdown, HTML or JSON', true],
|
|
109
|
+
['/copy', "copy the model's last answer to the clipboard"],
|
|
110
|
+
['/diff', 'every change HeartCode made to files in this session (add a file name to see one)', true],
|
|
111
|
+
['/init', 'look at the project and write AGENTS.md: its commands, conventions and layout'],
|
|
112
|
+
['/btw', 'ask something on the side, also while it works; the answer stays out of the conversation', true],
|
|
113
|
+
['/cost', 'tokens used and what they cost, this session'],
|
|
114
|
+
['/model_info', 'what the model can do and how HeartCode uses it'],
|
|
115
|
+
['/test_model', 'test the model for real (otherwise what it can do comes from models.dev, or your settings)'],
|
|
116
|
+
['/tidy_memory', 'merge, update and link the memory notes now'],
|
|
117
|
+
['/import', 'bring commands, skills, MCP servers and memory from other CLIs'],
|
|
118
|
+
['/login_mcp', 'sign in (again) to a remote MCP server', true],
|
|
119
|
+
['/compact', 'summarize the conversation to free up context (add what to keep)', true],
|
|
120
|
+
['/clear_chat', 'forget the conversation'],
|
|
121
|
+
['/reset', "HeartCode's settings (or also providers, or everything) back to the start; asks for a code first"],
|
|
122
|
+
['/settings', 'change providers, models, settings, MCP servers, hooks, keys and skills in your browser'],
|
|
123
|
+
['/help', 'this list'],
|
|
124
|
+
['/tab', 'a new tab (Ctrl+T): another conversation beside this one · /tab worktree [name]: in its own git worktree · /tab 2: go to tab 2 (Ctrl+N/P) · Ctrl+Q closes one', true],
|
|
125
|
+
['/exit', 'quit HeartCode (Ctrl+Q closes just this tab)'],
|
|
126
|
+
]],
|
|
127
|
+
]
|
|
128
|
+
const COMMANDS = SECTIONS.flatMap(([, list]) => list)
|
|
129
|
+
const helpText = (sections) => sections.map(([title, list]) => ` ${c.bold(title)}\n` + list.map(([n, d]) => ` ${c.cyan(n.padEnd(18))} ${d}`).join('\n')).join('\n\n')
|
|
130
|
+
const SLASH = helpText(SECTIONS)
|
|
131
|
+
|
|
132
|
+
const HOWTO = `${c.yellow('No model set up yet.')} Add a provider and choose its models in your browser:
|
|
133
|
+
|
|
134
|
+
${c.cyan('/settings')} (or ${c.cyan('heart settings')} in another terminal)
|
|
135
|
+
|
|
136
|
+
${c.dim('Ollama, LM Studio or llama.cpp running on this machine is found on its own.')}`
|
|
137
|
+
const NO_MODELS = `${c.yellow('No model chosen yet.')} Choose one on ${c.cyan('/settings')}: a provider's models, click one.`
|
|
138
|
+
|
|
139
|
+
// Choices for commands that take one (also offered in the "/" menu).
|
|
140
|
+
const ARGS = {
|
|
141
|
+
export: [['md', 'Markdown'], ['html', 'a web page'], ['json', 'the whole session file']],
|
|
142
|
+
set_effort: [['default', "the model's own setting"], ['low', 'quick, less thinking'], ['medium', 'balanced'], ['high', 'thinks the longest']],
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
// Terminal.app can't report Shift+Enter; Option+Enter works there ("Use Option as Meta key").
|
|
147
|
+
const NEWLINE_KEY = process.env.TERM_PROGRAM === 'Apple_Terminal' ? 'Option+Enter' : 'Shift+Enter'
|
|
148
|
+
|
|
149
|
+
const kTokens = (n) => (n >= 1e6 ? `${Math.round(n / 1e5) / 10}M` : n >= 1000 ? `${(n / 1000).toFixed(n >= 10_000 ? 0 : 1)}k` : String(n))
|
|
150
|
+
|
|
151
|
+
const mask = (k) => (k ? (k.length > 12 ? `${k.slice(0, 4)}…${k.slice(-4)}` : '…') : c.dim('no key'))
|
|
152
|
+
|
|
153
|
+
class App {
|
|
154
|
+
constructor(cfg, ui, root) {
|
|
155
|
+
Object.assign(this, { cfg, ui, root })
|
|
156
|
+
this.s = null // the Session, built on first use
|
|
157
|
+
this.lists = {} // provider → model ids, cached for this run
|
|
158
|
+
this.chat = newChat(root) // the conversation, saved after every message (sessions.js)
|
|
159
|
+
this.mcp = this.newMcp() // MCP servers (each starts when first used); cfg.mcp: --mcp-config
|
|
160
|
+
this.skills = loadSkills(root)
|
|
161
|
+
this.memory = new Memory(root) // .heartcode/memory: notes linked with [[name]]
|
|
162
|
+
this.director = new Director(this) // the manage tool: skills, MCP servers and settings by asking
|
|
163
|
+
this.later = [] // setting changes that wait until the current message is done
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
newMcp() {
|
|
167
|
+
const mcp = new Mcp(this.root, this.cfg.mcp)
|
|
168
|
+
// a remote server asks to sign in: its page opens in the browser (and the link is shown)
|
|
169
|
+
if (!this.ui.noUser)
|
|
170
|
+
mcp.onSignIn = (name, url) => {
|
|
171
|
+
this.ui.print(`${c.info('↗')} ${c.bold(name)} ${c.muted('needs you to sign in; opening your browser…')}\n ${c.faint(url)}`)
|
|
172
|
+
const [cmd, ...args] = process.platform === 'darwin' ? ['open', url] : process.platform === 'win32' ? ['cmd', '/c', 'start', '', url] : ['xdg-open', url]
|
|
173
|
+
spawnSync(cmd, args, { stdio: 'ignore' })
|
|
174
|
+
this.ui.spinner(`waiting for you to sign in to ${name} in the browser · Esc to stop`)
|
|
175
|
+
}
|
|
176
|
+
return mcp
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// After a skill or MCP server was saved or deleted (by the director or /import).
|
|
180
|
+
reload(kind) {
|
|
181
|
+
if (kind === 'skill') this.skills = loadSkills(this.root)
|
|
182
|
+
if (kind === 'mcp') {
|
|
183
|
+
this.mcp.dispose()
|
|
184
|
+
this.mcp = this.newMcp()
|
|
185
|
+
}
|
|
186
|
+
if (this.s) Object.assign(this.s, { skills: this.skills, mcp: this.mcp })
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
afterLine() {
|
|
190
|
+
for (const fn of this.later.splice(0)) fn()
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// The settings the director can change (manage, kind setting).
|
|
194
|
+
settings() {
|
|
195
|
+
const { cfg, ui } = this
|
|
196
|
+
return [
|
|
197
|
+
`permissions: ${ui.permission}`,
|
|
198
|
+
`effort: ${cfg.effort ?? 'default'}`,
|
|
199
|
+
`auto_compact: ${cfg.autoCompact === false ? 'off' : 'on'}`,
|
|
200
|
+
`director: ${cfg.director === false ? 'off' : 'on'}`,
|
|
201
|
+
`model: ${cfg.provider ?? '(none)'} ${cfg.model ?? ''}`,
|
|
202
|
+
`providers: ${Object.keys(cfg.providers).join(', ') || '(none)'}`,
|
|
203
|
+
].join('\n')
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
applySetting(name, value) {
|
|
207
|
+
const { cfg, ui } = this
|
|
208
|
+
const v = value.trim()
|
|
209
|
+
const bool = /^(on|true|yes)$/i.test(v) ? true : /^(off|false|no)$/i.test(v) ? false : null
|
|
210
|
+
const bad = (choices) => `error: ${name} must be ${choices}`
|
|
211
|
+
const save = (msg) => (saveConfig(cfg), log('director', `setting ${name} = ${v}`), msg)
|
|
212
|
+
switch (name) {
|
|
213
|
+
case 'permissions':
|
|
214
|
+
if (!PERMISSIONS.some(([p]) => p === v)) return bad('all, commands, ask or plan')
|
|
215
|
+
ui.setPermission(v)
|
|
216
|
+
return `permissions: ${v}`
|
|
217
|
+
case 'effort':
|
|
218
|
+
if (!ARGS.set_effort.some(([o]) => o === v)) return bad('default, low, medium or high')
|
|
219
|
+
cfg.effort = v === 'default' ? undefined : v
|
|
220
|
+
if (this.s?.strat.thinking) this.s.client.effort = cfg.effort
|
|
221
|
+
return save(`effort: ${v}`)
|
|
222
|
+
case 'auto_compact':
|
|
223
|
+
case 'director':
|
|
224
|
+
if (bool === null) return bad('on or off')
|
|
225
|
+
cfg[name === 'director' ? 'director' : 'autoCompact'] = bool
|
|
226
|
+
if (name === 'auto_compact' && this.s) this.s.autoCompact = bool
|
|
227
|
+
return save(`${name}: ${bool ? 'on' : 'off'}${name === 'director' ? ' (from the next message)' : ''}`)
|
|
228
|
+
case 'model': {
|
|
229
|
+
let [p, ...m] = v.split(/\s+/)
|
|
230
|
+
if (!cfg.providers[p]) [p, m] = [cfg.provider, [v]]
|
|
231
|
+
if (!cfg.providers[p] || !m.join(' ')) return `error: give "provider model" or a model of the current provider; providers: ${Object.keys(cfg.providers).join(', ')}`
|
|
232
|
+
this.later.push(() => this.use(p, m.join(' ')).catch((e) => ui.warn(e.message)))
|
|
233
|
+
return `switching to ${p} · ${m.join(' ')} after this reply`
|
|
234
|
+
}
|
|
235
|
+
default:
|
|
236
|
+
return `error: no setting "${name}"; manage list kind setting shows them`
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// The model, its probe and strategy for a provider and model (probed once, then cached).
|
|
241
|
+
async engine(provider, model, ui, signal) {
|
|
242
|
+
const { cfg } = this
|
|
243
|
+
const client = this.client(provider, model)
|
|
244
|
+
// tokens and cost, into the chat (also a helper's, the probe's, compaction's)
|
|
245
|
+
let price = priceOf(provider, client.baseUrl, model)
|
|
246
|
+
loadPrices().then(() => ((price = priceOf(provider, client.baseUrl, model)), ui.render?.()))
|
|
247
|
+
client.price = () => price
|
|
248
|
+
client.onSpend = (u) => addUsage((this.chat.usage ??= {}), u, price)
|
|
249
|
+
client.onRetry = (err, ms, attempt, next) => ui.retrying(err, ms, attempt, next) // keep trying until Esc (also while probing)
|
|
250
|
+
// the model list (settings page): another model answered; its price from now on
|
|
251
|
+
client.onSwitch = (t) => {
|
|
252
|
+
price = priceOf(t.provider, t.baseUrl, t.model)
|
|
253
|
+
const i = client.rotation.list.indexOf(t)
|
|
254
|
+
ui.print(c.dim(`↻ ${t.provider} · ${t.model} answered${i ? ` (#${i + 1} in your model list)` : ' (#1 again)'}`))
|
|
255
|
+
}
|
|
256
|
+
if (cfg.maxRetries !== undefined) client.retryIf = (e, attempt) => attempt < cfg.maxRetries // headless: then give up
|
|
257
|
+
const profile = await getProfile(client, ui, cfg.force, signal)
|
|
258
|
+
cfg.force = false
|
|
259
|
+
if (strategy(profile, cfg).thinking) client.effort = cfg.effort
|
|
260
|
+
const strat = strategy(profile, cfg)
|
|
261
|
+
// What the window is, learned while working (context.js); kept in the profile for next time.
|
|
262
|
+
const learned = () => {
|
|
263
|
+
Object.assign(strat, (({ context, contextSure, ratio, reply }) => ({ context, contextSure, ratio, reply }))(strategy(profile, cfg)))
|
|
264
|
+
Object.assign(client, { tokenRatio: profile.tokenRatio ?? 1, knownGood: profile.largestAccepted ?? 0 })
|
|
265
|
+
writeJson(profilePath(client), profile)
|
|
266
|
+
}
|
|
267
|
+
Object.assign(client, { tokenRatio: profile.tokenRatio ?? 1, knownGood: profile.largestAccepted ?? 0 })
|
|
268
|
+
client.onUsage = (used, estimate) => {
|
|
269
|
+
const was = profile.largestAccepted
|
|
270
|
+
const change = learnFromUsage(profile, used, estimate)
|
|
271
|
+
if (change === 'cut') ui.warn(`${model} silently cut a request to ~${profile.inputLimit} tokens: that's its window here; keeping the conversation within it`)
|
|
272
|
+
if (change) log('context', `${change}: ${used} tokens counted, ~${estimate} estimated; window now ${strategy(profile, cfg).context}`, { was, largestAccepted: profile.largestAccepted, inputLimit: profile.inputLimit, tokenRatio: profile.tokenRatio })
|
|
273
|
+
if (change) learned()
|
|
274
|
+
}
|
|
275
|
+
// The server said "too large" with its limit: remembered for this model (also next time). A
|
|
276
|
+
// per-minute cap is your account's at this provider: kept apart, for a day.
|
|
277
|
+
client.onInputLimit = (n, { rate = false } = {}) => {
|
|
278
|
+
if (rate) profile.rateCap = { tokens: n, until: Date.now() + RATE_CAP_MS }
|
|
279
|
+
else Object.assign(profile, { inputLimit: Math.min(profile.inputLimit ?? Infinity, n), inputLimitAt: Date.now() })
|
|
280
|
+
learned()
|
|
281
|
+
ui.warn(rate ? `${provider} takes at most ~${n} tokens a minute on your account; shortening the conversation to fit (for today)` : `${model} takes at most ~${n} tokens per request here; shortening the conversation to fit`)
|
|
282
|
+
}
|
|
283
|
+
// models.dev's published size for the model (a day old at most): used when the server doesn't say
|
|
284
|
+
loadPrices().then(() => {
|
|
285
|
+
const known = limitsOf(provider, client.baseUrl, model) ?? undefined
|
|
286
|
+
if (JSON.stringify(known) === JSON.stringify(profile.known)) return
|
|
287
|
+
profile.known = known
|
|
288
|
+
if (known && profile.source === 'assumed') profile.source = 'models.dev' // it's listed now
|
|
289
|
+
learned()
|
|
290
|
+
ui.render?.()
|
|
291
|
+
})
|
|
292
|
+
return { client, profile, strat }
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
setChat(chat) {
|
|
296
|
+
this.chat = chat
|
|
297
|
+
if (this.s) this.s.chat = chat
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
save() {
|
|
301
|
+
this.chat.log = this.ui.log
|
|
302
|
+
saveChat(this.chat)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// /btw: a side question, answered from the conversation so far and never added to it. While a
|
|
306
|
+
// task works it's typed into the busy input and runs at once (working: true), beside the task.
|
|
307
|
+
async btw(question, working = false) {
|
|
308
|
+
const { ui } = this
|
|
309
|
+
if (!question.trim()) return ui.print(`${c.cyan('/btw')} ${c.muted('<question>')} ${c.faint('asks something on the side, also while HeartCode works; the answer stays out of the conversation')}`)
|
|
310
|
+
const s = await this.session()
|
|
311
|
+
if (!s) return
|
|
312
|
+
if (!working) ui.spinner('answering on the side')
|
|
313
|
+
const bar = c.info('┃')
|
|
314
|
+
try {
|
|
315
|
+
const answer = await s.side(question, working ? undefined : this.signal)
|
|
316
|
+
if (!working) ui.stopSpin()
|
|
317
|
+
ui.print(`${bar} ${c.info(c.bold('btw'))} ${c.muted(clip(question.split('\n')[0], 100))}\n${(answer || c.faint('(no answer)')).split('\n').map((l) => `${bar} ${l}`).join('\n')}`)
|
|
318
|
+
} catch (e) {
|
|
319
|
+
if (!working) ui.stopSpin()
|
|
320
|
+
ui.print(`${bar} ${c.info(c.bold('btw'))} ${c.red(e.name === 'AbortError' ? 'stopped' : `failed: ${e.message}`)}`)
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// /resume: pick one of this folder's saved sessions and continue it.
|
|
325
|
+
async resume(chat) {
|
|
326
|
+
const { ui } = this
|
|
327
|
+
if (!chat) {
|
|
328
|
+
const chats = listChats(this.root).filter((x) => x.id !== this.chat.id)
|
|
329
|
+
if (!chats.length) return ui.print('No earlier sessions in this folder yet.')
|
|
330
|
+
const it = await ui.pick('Resume which session?', chats.map((x) => ({ label: x.title, desc: `${ago(x.updated)} · ${x.turns.length} message${x.turns.length > 1 ? 's' : ''}`, chat: x })))
|
|
331
|
+
if (!it) return
|
|
332
|
+
chat = it.chat
|
|
333
|
+
}
|
|
334
|
+
this.setChat(chat)
|
|
335
|
+
ui.setLog(chat.log)
|
|
336
|
+
ui.print(c.dim(`resumed "${chat.title}" · ${chat.turns.length} message${chat.turns.length > 1 ? 's' : ''} · ${ago(chat.updated)}`))
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// /rewind: back to before one of your messages; the conversation, the files HeartCode changed, or both.
|
|
340
|
+
async rewind() {
|
|
341
|
+
const { ui, chat } = this
|
|
342
|
+
if (!chat.turns.length) return ui.print('Nothing to rewind yet.')
|
|
343
|
+
const turns = chat.turns.map((t, i) => ({ t, i })).reverse()
|
|
344
|
+
const pickTurn = await ui.pick(
|
|
345
|
+
'Go back to before which message?',
|
|
346
|
+
turns.map(({ t, i }) => {
|
|
347
|
+
const n = Object.keys(chat.turns.slice(i).reduce((all, x) => Object.assign(all, x.files), {})).length
|
|
348
|
+
return { label: t.task.split('\n')[0].slice(0, 70), desc: `${ago(t.at)}${n ? ` · ${n} file${n > 1 ? 's' : ''} changed since` : ''}`, i }
|
|
349
|
+
}),
|
|
350
|
+
)
|
|
351
|
+
if (!pickTurn) return
|
|
352
|
+
const i = pickTurn.i
|
|
353
|
+
const turn = chat.turns[i]
|
|
354
|
+
const changed = Object.keys(chat.turns.slice(i).reduce((all, x) => Object.assign(all, x.files), {}))
|
|
355
|
+
const options = [
|
|
356
|
+
...(changed.length ? [{ label: 'Restore conversation and code', conversation: true, code: true }] : []),
|
|
357
|
+
{ label: 'Restore conversation', desc: changed.length ? 'keep the files as they are now' : '', conversation: true },
|
|
358
|
+
...(changed.length ? [{ label: 'Restore code', desc: `put back ${changed.join(', ')}; keep the conversation`, code: true }] : []),
|
|
359
|
+
{ label: 'Cancel' },
|
|
360
|
+
]
|
|
361
|
+
const how = await ui.pick('Restore what?', options)
|
|
362
|
+
if (!how || (!how.conversation && !how.code)) return
|
|
363
|
+
if (how.code && !how.conversation) {
|
|
364
|
+
const files = restoreFiles(chat, i)
|
|
365
|
+
log('rewind', `files restored to before "${turn.task}":`, files)
|
|
366
|
+
for (const t of chat.turns.slice(i)) t.files = {}
|
|
367
|
+
chat.messages?.push({ role: 'user', content: prompt('agent/files-restored', { task: turn.task, files: files.join(', ') }) })
|
|
368
|
+
ui.print(c.dim(`restored ${files.join(', ')} to before "${turn.task.split('\n')[0].slice(0, 60)}"`))
|
|
369
|
+
}
|
|
370
|
+
if (how.conversation) {
|
|
371
|
+
// what came after is kept as a branch (/tree), never lost: with its files as they are now,
|
|
372
|
+
// so the move puts the files back (not restoreFiles before it)
|
|
373
|
+
chat.log = ui.log
|
|
374
|
+
const { files } = move(chat, null, i, { code: Boolean(how.code) })
|
|
375
|
+
log('rewind', `files restored to before "${turn.task}":`, files)
|
|
376
|
+
ui.setLog(chat.log)
|
|
377
|
+
if (how.code) ui.print(c.dim(`restored ${files.join(', ') || 'nothing (no file differed)'}`))
|
|
378
|
+
ui.print(c.dim('the messages after it are kept as a branch: /tree goes back to them'))
|
|
379
|
+
this.nextInput = turn.task // back in the input, to edit and send again
|
|
380
|
+
}
|
|
381
|
+
log('rewind', `to before message ${i + 1}`, { conversation: Boolean(how.conversation), code: Boolean(how.code) })
|
|
382
|
+
if (this.s) this.s.client.lastTokens = 0
|
|
383
|
+
this.save()
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// /tree: every message of this session, on every branch; go to any of them. The branch you
|
|
387
|
+
// leave is kept, the files can follow, and a summary of what you leave can come along.
|
|
388
|
+
async tree() {
|
|
389
|
+
const { ui, chat } = this
|
|
390
|
+
if (!chat.turns.length && !chat.branches?.length) return ui.print('Nothing here yet: the tree grows as you send messages and go back (/rewind, /tree).')
|
|
391
|
+
chat.log = ui.log
|
|
392
|
+
const n = (chat.branches?.length ?? 0) + 1
|
|
393
|
+
const it = await ui.pick(`This session${n > 1 ? `, ${n} branches` : ''} · pick a message`, treeItems(chat, ago))
|
|
394
|
+
if (!it) return
|
|
395
|
+
const atEnd = !it.branch && it.keep + 1 === chat.turns.length // the last message where you are
|
|
396
|
+
const go = await ui.pick('Go there how?', [
|
|
397
|
+
...(atEnd ? [] : [{ label: 'Continue after this message', desc: it.end ? 'where that branch ended' : 'its answer and what came before it', keep: it.keep + 1 }]),
|
|
398
|
+
{ label: 'Edit this message and send it again', desc: 'a new branch from just before it', keep: it.keep, edit: true },
|
|
399
|
+
{ label: 'Cancel' },
|
|
400
|
+
])
|
|
401
|
+
if (go?.keep === undefined) return
|
|
402
|
+
const { plan, unknown } = codePlan(chat, it.branch, go.keep)
|
|
403
|
+
let code = true
|
|
404
|
+
if (plan.length) {
|
|
405
|
+
const names = plan.map(([rel]) => rel)
|
|
406
|
+
const how = await ui.pick('And the files?', [
|
|
407
|
+
{ label: 'Switch them too', desc: `${names.length} file${names.length > 1 ? 's' : ''} as they were there: ${clip(names.join(', '), 80)}`, code: true },
|
|
408
|
+
{ label: 'Keep them as they are now', code: false },
|
|
409
|
+
])
|
|
410
|
+
if (!how) return
|
|
411
|
+
code = how.code
|
|
412
|
+
}
|
|
413
|
+
let summarize = false
|
|
414
|
+
if (leaving(chat, it.branch, go.keep) && (this.s || this.cfg.providers[this.cfg.provider])) {
|
|
415
|
+
const a = await ui.pick('Bring a summary of the messages you leave?', [
|
|
416
|
+
{ label: 'Yes', desc: 'one request to the model, so this branch knows what was tried there', yes: true },
|
|
417
|
+
{ label: 'No', desc: 'they stay in the tree, the model just won\'t know about them' },
|
|
418
|
+
])
|
|
419
|
+
if (!a) return
|
|
420
|
+
summarize = Boolean(a.yes)
|
|
421
|
+
}
|
|
422
|
+
const { left, files } = move(chat, it.branch, go.keep, { code })
|
|
423
|
+
ui.setLog(chat.log)
|
|
424
|
+
if (files.length) ui.print(c.dim(`files now as on this branch: ${files.join(', ')}`))
|
|
425
|
+
if (code && unknown.length) ui.warn(`not known how ${unknown.join(', ')} looked there; left as they are`)
|
|
426
|
+
if (summarize) {
|
|
427
|
+
const s = await this.session()
|
|
428
|
+
if (s) {
|
|
429
|
+
ui.spinner('summarizing the branch you left')
|
|
430
|
+
try {
|
|
431
|
+
const changedThere = leftFiles(left)
|
|
432
|
+
const summary = await s.summarizeBranch(leftText(left, transcript), changedThere, this.signal)
|
|
433
|
+
chat.branchNote = prompt('compact/branch-note', { summary, files: changedThere.join(', '), kept: changedThere.length && !code, restored: changedThere.length && code })
|
|
434
|
+
ui.stopSpin()
|
|
435
|
+
ui.print(`${c.faint('✻')} ${c.muted(`summary of the branch you left (goes with your next message): ${clip(summary.split('\n').find((l) => l.trim()) ?? '', 120)}`)}`)
|
|
436
|
+
} catch (e) {
|
|
437
|
+
ui.stopSpin()
|
|
438
|
+
if (e.name === 'AbortError') throw e
|
|
439
|
+
ui.warn(`no summary: ${e.message}`)
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
ui.print(c.dim(`${go.edit ? 'edit the message and send it: a new branch' : 'here now'} · the branch you left stays in /tree`))
|
|
444
|
+
if (go.edit) this.nextInput = it.task
|
|
445
|
+
if (this.s) this.s.client.lastTokens = 0
|
|
446
|
+
this.save()
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Built-in commands, then the skills you can type (a built-in wins on the same name).
|
|
450
|
+
commands() {
|
|
451
|
+
const builtIn = new Set(COMMANDS.map(([n]) => n))
|
|
452
|
+
const mine = [...this.skills.values()].filter((k) => k.use !== 'model' && !builtIn.has(`/${k.name}`)).map((k) => [`/${k.name}`, k.description, true])
|
|
453
|
+
return [...COMMANDS, ...mine]
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
help() {
|
|
457
|
+
const mine = this.commands().slice(COMMANDS.length)
|
|
458
|
+
return helpText(mine.length ? [...SECTIONS, ['your skills', mine]] : SECTIONS)
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// "/review focus on tests": the skill's text, arguments filled in, `!command` output added.
|
|
462
|
+
async expandSkill(k, args) {
|
|
463
|
+
let text = fillArgs(k.body, args).replace(/(^|\s)@([\w./-]+)/g, '$1$2') // @file: HeartCode attaches files a message names
|
|
464
|
+
for (const m of [...text.matchAll(/!`([^`]+)`|```!\n([\s\S]*?)```/g)]) {
|
|
465
|
+
const cmd = (m[1] ?? m[2]).trim()
|
|
466
|
+
const ok = await this.ui.confirm(`Run ${c.bold(cmd)} (from /${k.name})?`, 'command')
|
|
467
|
+
const out = ok === true ? (await runCommand(this.root, cmd, { signal: this.signal })).output.trim() : '(not run)'
|
|
468
|
+
log('skill', `/${k.name} ran ${cmd}`)
|
|
469
|
+
text = text.replace(m[0], () => `\n\`\`\`\n$ ${cmd}\n${clip(out, 8000)}\n\`\`\`\n`)
|
|
470
|
+
}
|
|
471
|
+
return text
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// What the menu under the prompt offers for the text before the cursor.
|
|
475
|
+
suggest(line, cursor) {
|
|
476
|
+
const before = line.slice(0, cursor)
|
|
477
|
+
const end = cursor + (/^\S*/.exec(line.slice(cursor))?.[0].length ?? 0)
|
|
478
|
+
let m = /^\/(\S*)$/.exec(before)
|
|
479
|
+
if (m) {
|
|
480
|
+
const items = this.commands().map(([value, desc, args]) => ({ value, desc, space: args, start: 0, end }))
|
|
481
|
+
const hits = rank(items, '/' + m[1])
|
|
482
|
+
return hits.length === 1 && hits[0].value === before ? [] : hits
|
|
483
|
+
}
|
|
484
|
+
m = /^\/(\w+)\s+(?:(\S+)\s+)?(\S*)$/.exec(before)
|
|
485
|
+
if (m) {
|
|
486
|
+
const [, cmd, first, typed] = m
|
|
487
|
+
const start = cursor - typed.length
|
|
488
|
+
const opts = (list) => rank(list.map(([value, desc]) => ({ value, desc, start, end })), typed)
|
|
489
|
+
if (ARGS[cmd] && !first) return opts(ARGS[cmd])
|
|
490
|
+
if (cmd === 'select_model' && !first) return this.modelChoices(typed, start, end)
|
|
491
|
+
return []
|
|
492
|
+
}
|
|
493
|
+
m = /(?:^|\s)@(\S*)$/.exec(before)
|
|
494
|
+
if (m) {
|
|
495
|
+
if (!this.files || Date.now() - this.filesAt > 5000) [this.files, this.filesAt] = [listFiles(this.root), Date.now()]
|
|
496
|
+
const start = cursor - m[1].length
|
|
497
|
+
return rank(this.files.map((f) => ({ value: f, start, end, insertOnly: true })), m[1]).slice(0, 50)
|
|
498
|
+
}
|
|
499
|
+
return []
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// Your models (the list on /settings: #1 and the fallbacks), for the "/select_model " menu.
|
|
503
|
+
modelChoices(typed, start, end) {
|
|
504
|
+
const mine = this.myModels()
|
|
505
|
+
const twice = (m) => mine.filter((t) => t.model === m).length > 1
|
|
506
|
+
const items = mine.map((t, i) => ({ value: twice(t.model) ? `${t.provider} ${t.model}` : t.model, desc: `${t.provider} · #${i + 1}${i === 0 ? ' · in use' : ''}`, start, end }))
|
|
507
|
+
return rank(items, typed).slice(0, 200)
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// The status bar: what's in use, its abilities, and how full the context is.
|
|
511
|
+
status() {
|
|
512
|
+
const { cfg, ui } = this
|
|
513
|
+
const sep = c.ghost(' │ ')
|
|
514
|
+
if (!cfg.providers[cfg.provider] || !cfg.model) return c.dim('no model · /settings to choose one')
|
|
515
|
+
const rot = this.s?.client.rotation
|
|
516
|
+
const now = rot?.list[rot.at] ?? { provider: cfg.provider, model: cfg.model } // the model list: the one in use
|
|
517
|
+
const parts = [`${c.brand('●')} ${c.muted(now.provider)} ${c.ghost('·')} ${c.info(now.model)}${rot ? c.faint(` ↻ ${rot.at + 1}/${rot.list.length}`) : ''}`]
|
|
518
|
+
const key = `${cfg.provider}/${cfg.model}`
|
|
519
|
+
if (this.profileKey !== key) [this.profileKey, this.profile] = [key, this.s?.profile ?? loadProfile(this.client(cfg.provider, cfg.model))]
|
|
520
|
+
const profile = this.s?.profile ?? this.profile
|
|
521
|
+
if (profile) {
|
|
522
|
+
const strat = this.s?.strat ?? strategy(profile, cfg)
|
|
523
|
+
parts.push(c.muted(strat.actions === 'tools' ? 'tools' : 'guided'))
|
|
524
|
+
if (strat.thinking) parts.push(c.think(`thinking${cfg.effort ? ` · ${cfg.effort}` : ''}`) + (ui.showThinking ? '' : c.faint(' (collapsed)')))
|
|
525
|
+
if (strat.vision) parts.push(c.muted('images'))
|
|
526
|
+
const used = this.s?.client.lastTokens ?? 0
|
|
527
|
+
const pct = Math.round((used / strat.context) * 100)
|
|
528
|
+
const paint = pct >= 80 ? c.red : pct >= 50 ? c.yellow : c.green
|
|
529
|
+
const guess = strat.contextSure ? '' : '~' // not said by the server: assumed, and growing as bigger requests work
|
|
530
|
+
parts.push(`${c.faint('ctx')} ${paint(`${kTokens(used)}/${guess}${kTokens(strat.context)}`)} ${c.faint(`${pct}%`)}`)
|
|
531
|
+
const tps = this.s?.client.tps
|
|
532
|
+
if (tps) parts.push(`${c.muted(String(tps))} ${c.faint('tok/s')}`)
|
|
533
|
+
if (this.chat.usage?.priced) parts.push(c.muted(money(this.chat.usage.cost)))
|
|
534
|
+
} else {
|
|
535
|
+
parts.push(c.dim('not probed yet'))
|
|
536
|
+
}
|
|
537
|
+
if (cfg.autoCompact === false) parts.push(c.faint('auto-compact off'))
|
|
538
|
+
if (this.worktree) parts.push(c.info(`⎇ ${this.worktree.branch}`))
|
|
539
|
+
|
|
540
|
+
const perm = { all: c.green('⏵⏵ all allowed'), commands: c.yellow('ask before commands'), ask: c.yellow('ask before changes'), plan: c.info('⏸ plan mode') }[ui.permission]
|
|
541
|
+
parts.push(perm) // Shift+Tab changes it (see the header)
|
|
542
|
+
return parts.join(sep)
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
client(name, model) {
|
|
546
|
+
const p = this.cfg.providers[name]
|
|
547
|
+
return new Client({ baseUrl: p.baseUrl, apiKey: p.apiKey, model, provider: name })
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
// With nothing configured, use a local server if one is running.
|
|
552
|
+
async detectLocal() {
|
|
553
|
+
for (const url of LOCAL_SERVERS) {
|
|
554
|
+
try {
|
|
555
|
+
const models = await new Client({ baseUrl: url }).listModels(800)
|
|
556
|
+
const name = providerName(url)
|
|
557
|
+
this.cfg.providers[name] = { baseUrl: url }
|
|
558
|
+
this.cfg.adhoc = name
|
|
559
|
+
this.cfg.provider = name
|
|
560
|
+
this.cfg.model ??= models[0]
|
|
561
|
+
this.lists[name] = models
|
|
562
|
+
return true
|
|
563
|
+
} catch {}
|
|
564
|
+
}
|
|
565
|
+
return false
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// The Session for the current provider and model, or null (after saying how to set one up).
|
|
569
|
+
async session() {
|
|
570
|
+
if (this.s) return this.s
|
|
571
|
+
const { ui, cfg } = this
|
|
572
|
+
if (!Object.keys(cfg.providers).length) await this.detectLocal()
|
|
573
|
+
if (!cfg.providers[cfg.provider]) {
|
|
574
|
+
if (!Object.keys(cfg.providers).length) ui.print(HOWTO)
|
|
575
|
+
else ui.print(NO_MODELS)
|
|
576
|
+
return null
|
|
577
|
+
}
|
|
578
|
+
if (!cfg.model) {
|
|
579
|
+
ui.print(NO_MODELS)
|
|
580
|
+
return null
|
|
581
|
+
}
|
|
582
|
+
const { client, profile, strat } = await this.engine(cfg.provider, cfg.model, ui, this.signal)
|
|
583
|
+
this.s = new Session({ client, profile, strat, root: this.root, ui, chat: this.chat }) // details: the status bar
|
|
584
|
+
this.s.autoCompact = cfg.autoCompact !== false
|
|
585
|
+
this.s.onTurn = () => this.save()
|
|
586
|
+
this.useRotation()
|
|
587
|
+
Object.assign(this.s, { noFormat: cfg.format === false, noLsp: cfg.lsp === false })
|
|
588
|
+
Object.assign(this.s, { mcp: this.mcp, memory: this.memory, skills: this.skills, director: this.director })
|
|
589
|
+
if (this.allowTools?.length) this.s.allowTools = toolNames(this.allowTools) // headless --allowedTools
|
|
590
|
+
if (this.denyTools?.length) this.s.denyTools = toolNames(this.denyTools)
|
|
591
|
+
if (this.cfg.maxTurns) this.s.maxTurns = this.cfg.maxTurns
|
|
592
|
+
log('session', `${cfg.provider} · ${cfg.model} at ${client.baseUrl}`, { strategy: this.s.strat, effort: client.effort })
|
|
593
|
+
return this.s
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// The model list (settings page): this model first, then the others in order; set on the session.
|
|
597
|
+
// A model given for this run (-m, not "default") is used alone: no fallbacks (cfg.fixed).
|
|
598
|
+
useRotation() {
|
|
599
|
+
const { cfg } = this
|
|
600
|
+
const seen = new Set()
|
|
601
|
+
const list = [{ provider: cfg.provider, model: cfg.model }, ...(cfg.fixed ? [] : (cfg.rotation?.then ?? []))]
|
|
602
|
+
.filter((t) => cfg.providers[t.provider] && t.model && !seen.has(`${t.provider}/${t.model}`) && seen.add(`${t.provider}/${t.model}`))
|
|
603
|
+
.map((t) => ({ ...t, baseUrl: cfg.providers[t.provider].baseUrl, apiKey: cfg.providers[t.provider].apiKey }))
|
|
604
|
+
this.s?.client.setRotation(list, cfg.rotation ?? {})
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
async use(name, model) {
|
|
608
|
+
Object.assign(this.cfg, { provider: name, model })
|
|
609
|
+
this.s = null
|
|
610
|
+
if (name !== this.cfg.adhoc) saveConfig(this.cfg)
|
|
611
|
+
this.ui.print(`${c.green('using')} ${name} · ${c.cyan(model)}`)
|
|
612
|
+
await this.session()
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// Your models, in order: this one (#1), then the fallbacks (chosen on /settings, where every
|
|
616
|
+
// provider's whole list is).
|
|
617
|
+
myModels() {
|
|
618
|
+
const { cfg } = this
|
|
619
|
+
const seen = new Set()
|
|
620
|
+
return [{ provider: cfg.provider, model: cfg.model }, ...(cfg.rotation?.then ?? [])].filter((t) => cfg.providers[t.provider] && t.model && !seen.has(`${t.provider}/${t.model}`) && seen.add(`${t.provider}/${t.model}`))
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// Makes one of your models #1 (the rest keep their order); saved for next time.
|
|
624
|
+
async useMine(t) {
|
|
625
|
+
const { cfg } = this
|
|
626
|
+
delete cfg.fixed // one of your models: your list again, with its fallbacks
|
|
627
|
+
const rest = this.myModels().filter((x) => x.provider !== t.provider || x.model !== t.model)
|
|
628
|
+
cfg.rotation = { ...cfg.rotation, then: rest }
|
|
629
|
+
await this.use(t.provider, t.model)
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// The picker: your models only, #1 first; Enter makes it #1.
|
|
633
|
+
async chooseModel(filter = '') {
|
|
634
|
+
const { ui, cfg } = this
|
|
635
|
+
const mine = this.myModels()
|
|
636
|
+
if (!mine.length) {
|
|
637
|
+
if (!Object.keys(cfg.providers).length && (await this.detectLocal())) return this.session()
|
|
638
|
+
return ui.print(NO_MODELS)
|
|
639
|
+
}
|
|
640
|
+
const items = mine.map((t, i) => ({ label: t.model, group: t.provider, t, current: i === 0, desc: [i === 0 ? '#1 · in use' : `#${i + 1} · fallback`, loadProfile(this.client(t.provider, t.model)) && '✓ tested'].filter(Boolean).join(' · ') }))
|
|
641
|
+
const it = await ui.pick('Choose a model (more on /settings)', items, { filter })
|
|
642
|
+
if (it && !it.current) await this.useMine(it.t)
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// "/select_model gpt-oss-120b" or "/select_model groq openai/gpt-oss-120b": one of your models;
|
|
646
|
+
// no argument, or one that's not in your list: the picker.
|
|
647
|
+
async pickModel(args) {
|
|
648
|
+
if (!args.length) return this.chooseModel()
|
|
649
|
+
const [name, model] = args.length > 1 ? [args[0], args.slice(1).join(' ')] : [null, args[0]]
|
|
650
|
+
const found = this.myModels().filter((t) => t.model === model && (!name || t.provider === name))
|
|
651
|
+
if (found.length === 1) return found[0].provider === this.cfg.provider && found[0].model === this.cfg.model ? this.ui.print(c.dim(`${model} is in use already`)) : this.useMine(found[0])
|
|
652
|
+
if (!found.length) this.ui.print(c.dim(`${model} isn't one of your models: add it on /settings`))
|
|
653
|
+
return this.chooseModel(found.length ? model : '')
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
listProviders() {
|
|
657
|
+
const { ui, cfg } = this
|
|
658
|
+
const names = Object.keys(cfg.providers)
|
|
659
|
+
if (!names.length) return ui.print(`No providers yet: add one with ${c.cyan('heart settings')}.`)
|
|
660
|
+
for (const n of names) {
|
|
661
|
+
const p = cfg.providers[n]
|
|
662
|
+
const notes = [n === cfg.provider && c.magenta('● current'), n === cfg.adhoc && c.dim('this run only')].filter(Boolean).join(' ')
|
|
663
|
+
ui.print(`${c.bold(n.padEnd(14))} ${p.baseUrl} ${c.dim(mask(p.apiKey))} ${notes}`)
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// `heart models`: your models, in order (every provider's whole list is on the settings page).
|
|
668
|
+
// all: every provider's models too (fetched from each; a provider whose list is switched off: the
|
|
669
|
+
// ones added by name), yours marked. json: one JSON object on stdout, for other programs:
|
|
670
|
+
// { "models": [{ "place": null, "provider": null, "model": "default", "role": "default", "rotates": true, "about": "…" },
|
|
671
|
+
// { "place": 1, "provider", "model", "role": "in use" | "fallback", "rotates": false }, …],
|
|
672
|
+
// "providers": [{ "name", "baseUrl", "list": true, "models": ["…"], "error"? }] } (--all)
|
|
673
|
+
// "default" is the name for your list: -m default (or no -m) uses #1 and moves on to the next when
|
|
674
|
+
// one fails; -m with one of the models is that model alone.
|
|
675
|
+
async listModels(filter = '', { all = false, json = false } = {}) {
|
|
676
|
+
const { ui, cfg } = this
|
|
677
|
+
const match = (id) => id.toLowerCase().includes(filter.toLowerCase())
|
|
678
|
+
const mine = this.myModels().map((t, i) => ({ place: i + 1, provider: t.provider, model: t.model, role: i === 0 ? 'in use' : 'fallback', rotates: false }))
|
|
679
|
+
const placeOf = (provider, model) => mine.find((t) => t.provider === provider && t.model === model)?.place
|
|
680
|
+
const providers = !all ? [] : await Promise.all(Object.entries(cfg.providers).map(async ([name, p]) => {
|
|
681
|
+
const out = { name, baseUrl: p.baseUrl, list: p.list !== false, models: [] }
|
|
682
|
+
if (out.list) {
|
|
683
|
+
try {
|
|
684
|
+
out.models = await new Client({ baseUrl: p.baseUrl, apiKey: p.apiKey }).listModels()
|
|
685
|
+
} catch (e) {
|
|
686
|
+
out.error = e.message
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
// and the ones added by name, and any of yours it doesn't list (as on the settings page)
|
|
690
|
+
out.models = [...new Set([...out.models, ...(p.models ?? []), ...mine.filter((t) => t.provider === name).map((t) => t.model)])].sort((a, b) => a.localeCompare(b)).filter(match)
|
|
691
|
+
return out
|
|
692
|
+
}))
|
|
693
|
+
// "default" first, in the same list: your models, rotating (when there's any)
|
|
694
|
+
const about = mine.length > 1 ? `your models in order: #1, then the next when one fails (-m ${DEFAULT_MODEL}, or no -m)` : `your one model, #1 (-m ${DEFAULT_MODEL}, or no -m)`
|
|
695
|
+
const first = mine.length ? [{ place: null, provider: null, model: DEFAULT_MODEL, role: 'default', rotates: mine.length > 1, about }] : []
|
|
696
|
+
const shown = [...first, ...mine].filter((t) => match(t.model))
|
|
697
|
+
if (json) {
|
|
698
|
+
process.stdout.write(JSON.stringify({ models: shown, ...(all && { providers }) }, null, 2) + '\n')
|
|
699
|
+
return
|
|
700
|
+
}
|
|
701
|
+
if (!all) {
|
|
702
|
+
if (!shown.length) return ui.print(filter ? `None of your models matches "${filter}".` : NO_MODELS)
|
|
703
|
+
for (const t of shown) ui.print(t.place === null ? `${c.magenta('●')} ${c.bold(t.model)} ${c.dim(t.about)}` : ` ${c.bold(`#${t.place}`)} ${t.model} ${c.dim(`${t.provider} · ${t.role}; alone with -m "${t.provider} ${t.model}"`)}`)
|
|
704
|
+
return
|
|
705
|
+
}
|
|
706
|
+
if (!providers.length) return ui.print(NO_MODELS)
|
|
707
|
+
for (const p of providers) {
|
|
708
|
+
ui.print(`${c.bold(p.name)} ${c.dim(p.baseUrl)}${p.list ? '' : c.dim(' (its list is off: the models added by name)')}`)
|
|
709
|
+
if (p.error) ui.print(` ${c.red("can't list its models:")} ${c.dim(p.error)}`)
|
|
710
|
+
if (!p.models.length && !p.error) ui.print(c.dim(filter ? ` none matches "${filter}"` : ' no models'))
|
|
711
|
+
for (const id of p.models) {
|
|
712
|
+
const at = placeOf(p.name, id)
|
|
713
|
+
ui.print(` ${at === 1 ? c.magenta('●') : ' '} ${id}${at ? ` ${c.bold(`#${at}`)} ${c.dim(at === 1 ? 'in use' : 'fallback')}` : ''}`)
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// Leaving a --worktree session: nothing changed → it's removed; else you choose (it's kept by default).
|
|
720
|
+
async function leaveWorktree(app) {
|
|
721
|
+
const wt = app.worktree
|
|
722
|
+
if (!wt) return
|
|
723
|
+
const st = worktreeState(wt)
|
|
724
|
+
wt.done = true
|
|
725
|
+
if (st.empty) {
|
|
726
|
+
removeWorktree(wt)
|
|
727
|
+
return console.log(c.dim(`worktree ${wt.name}: nothing changed, removed`))
|
|
728
|
+
}
|
|
729
|
+
const what = [st.changes && `${st.changes} uncommitted change${st.changes > 1 ? 's' : ''}`, st.commits && `${st.commits} commit${st.commits > 1 ? 's' : ''}`].filter(Boolean).join(' and ')
|
|
730
|
+
const rl = (await import('node:readline/promises')).createInterface({ input: process.stdin, output: process.stdout })
|
|
731
|
+
const a = process.stdin.isTTY ? await rl.question(`worktree ${wt.name} has ${what}. Keep it? [Y/n, or "remove"] `) : ''
|
|
732
|
+
rl.close()
|
|
733
|
+
if (/^(n|no|remove)$/i.test(a.trim())) {
|
|
734
|
+
removeWorktree(wt)
|
|
735
|
+
return console.log(c.dim(`removed ${wt.dir} and the branch ${wt.branch}`))
|
|
736
|
+
}
|
|
737
|
+
console.log(`kept ${c.info(wt.dir)} on ${c.info(wt.branch)}\n${c.dim(`continue: heartcode --worktree=${wt.name} · merge: git merge ${wt.branch} (commit in the worktree first) · remove: git worktree remove ${wt.dir}`)}`)
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// Which conversations are open in tabs, per folder (~/.heart/tabs.json), to offer them again.
|
|
741
|
+
const TABS_FILE = path.join(HOME, 'tabs.json')
|
|
742
|
+
function saveTabs(tabs) {
|
|
743
|
+
const all = readJson(TABS_FILE, {})
|
|
744
|
+
all[tabs.root] = { at: tabs.list.indexOf(tabs.at), tabs: tabs.list.map((t) => ({ chat: t.app.chat.id, worktree: t.app.worktree?.name ?? null, provider: t.app.cfg.provider ?? null, model: t.app.cfg.model ?? null })) }
|
|
745
|
+
writeJson(TABS_FILE, all)
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// Starting in a folder with an earlier session: continue it? With every tab it had open that has a
|
|
749
|
+
// message sent (answered or not; a worktree tab if its worktree is still there). A last run with
|
|
750
|
+
// nothing sent: nothing to continue. No record of one (sessions from before tabs, or -p): the
|
|
751
|
+
// last session here.
|
|
752
|
+
async function continueTabs(app, saved) {
|
|
753
|
+
const { ui, tabs } = app
|
|
754
|
+
let open = (saved?.tabs ?? []).map((t, i) => ({ ...t, i, chat: loadChat(t.chat) })).filter((t) => t.chat?.turns?.length && (!t.worktree || fs.existsSync(t.chat.root)))
|
|
755
|
+
if (!saved) open = listChats(app.root).filter((c) => c.turns?.length).slice(0, 1).map((chat) => ({ chat, worktree: null }))
|
|
756
|
+
if (!open.length) return
|
|
757
|
+
const many = open.length > 1
|
|
758
|
+
const newest = open.map((t) => t.chat.updated).sort().at(-1)
|
|
759
|
+
const go = await ui.pick(many ? `Continue where you left off? ${open.length} tabs` : 'Continue your last session here?', [
|
|
760
|
+
{ label: many ? `Yes, with its ${open.length} tabs` : 'Yes', desc: `${open.map((t) => `"${t.chat.title}"`).join(', ')} · ${ago(newest)}`, yes: true },
|
|
761
|
+
{ label: 'No, start a new session', desc: '/resume finds it later' },
|
|
762
|
+
], { quiet: true })
|
|
763
|
+
if (!go?.yes) return
|
|
764
|
+
// this tab takes the first one in this folder; the rest (and worktree ones) open as tabs
|
|
765
|
+
const here = open.find((t) => !t.worktree) ?? null
|
|
766
|
+
if (here) {
|
|
767
|
+
await app.resume(here.chat)
|
|
768
|
+
useModel(app, here) // after: resuming redraws the screen
|
|
769
|
+
}
|
|
770
|
+
const tabOf = new Map(here ? [[here, tabs.at]] : [])
|
|
771
|
+
for (const t of open.filter((x) => x !== here)) {
|
|
772
|
+
try {
|
|
773
|
+
const wt = t.worktree ? createWorktree(app.root, t.worktree) : null
|
|
774
|
+
tabOf.set(t, tabs.add({ root: wt?.cwd ?? app.root, wt, chat: t.chat, model: t }))
|
|
775
|
+
} catch (e) {
|
|
776
|
+
ui.warn(`"${t.chat.title}": ${e.message}`)
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
tabs.show(tabOf.get(open.find((t) => t.i === saved?.at)) ?? tabs.list[0])
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// A continued tab's own model, as it was (unless its provider is gone: then the current one).
|
|
783
|
+
function useModel(app, { provider, model }) {
|
|
784
|
+
if (!provider || !model || (provider === app.cfg.provider && model === app.cfg.model)) return
|
|
785
|
+
if (!app.cfg.providers[provider]) return app.ui.warn(`this tab used ${provider} · ${model}, but there's no provider "${provider}" now: using ${app.cfg.provider} · ${app.cfg.model}`)
|
|
786
|
+
Object.assign(app.cfg, { provider, model })
|
|
787
|
+
app.s = null
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// The settings page (/settings): on while its dialog is (one at a time).
|
|
791
|
+
let settingsPage = null
|
|
792
|
+
|
|
793
|
+
function openBrowser(url) {
|
|
794
|
+
if (process.env.HEART_NO_BROWSER) return
|
|
795
|
+
const [cmd, ...args] = process.platform === 'darwin' ? ['open', url] : process.platform === 'win32' ? ['cmd', '/c', 'start', '', url] : ['xdg-open', url]
|
|
796
|
+
spawnSync(cmd, args, { stdio: 'ignore' })
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// A change on the settings page (in cfg, saved), brought into every open tab: right away, or the
|
|
800
|
+
// model and MCP servers after the reply in progress.
|
|
801
|
+
function applySettings(apps, cfg, what) {
|
|
802
|
+
const before = themeCodes()
|
|
803
|
+
if (what === 'theme' || what === 'setting:theme') setTheme(cfg.theme)
|
|
804
|
+
if (what === 'prompts') reloadPrompts() // from each tab's next message
|
|
805
|
+
if (['setting:log', 'setting:logRequests', 'setting:logDetail'].includes(what)) useRequestLog(cfg) // the run's log and the requests', on or off now
|
|
806
|
+
if (what === 'setting:logDays') pruneLogs(logDays(cfg))
|
|
807
|
+
settingsSeen(what) // the watcher (watchSettings) mustn't take it in a second time
|
|
808
|
+
for (const app of apps) {
|
|
809
|
+
const { ui } = app
|
|
810
|
+
const busy = Boolean(ui.busySince)
|
|
811
|
+
const now = (fn) => (busy ? app.later.push(fn) : fn())
|
|
812
|
+
if (app.cfg !== cfg) for (const k of ['providers', 'provider', 'model', 'effort', 'theme', 'context', 'autoCompact', 'director', 'format', 'lsp', 'voice', 'rotation']) app.cfg[k] = cfg[k]
|
|
813
|
+
if (what === 'model') {
|
|
814
|
+
app.lists = {}
|
|
815
|
+
delete app.cfg.fixed // a model chosen on the page: your list again
|
|
816
|
+
const want = cfg.providers[cfg.provider] && app.client(cfg.provider, cfg.model)
|
|
817
|
+
if (app.s && !(want && ['model', 'baseUrl', 'apiKey'].every((k) => want[k] === app.s.client[k])))
|
|
818
|
+
now(() => {
|
|
819
|
+
app.s = null
|
|
820
|
+
ui.print(c.dim(cfg.model ? `settings: ${cfg.provider} · ${cfg.model} from the next message` : 'settings: no model chosen'))
|
|
821
|
+
})
|
|
822
|
+
}
|
|
823
|
+
if (what === 'provider') app.lists = {} // its list switched on or off, a model added by name
|
|
824
|
+
if (what === 'setting:permissions') ui.setPermission(cfg.permissions)
|
|
825
|
+
if (what === 'setting:effort' && app.s?.strat.thinking) app.s.client.effort = cfg.effort
|
|
826
|
+
if (what === 'setting:autoCompact' && app.s) app.s.autoCompact = cfg.autoCompact !== false
|
|
827
|
+
if (what === 'setting:format' && app.s) app.s.noFormat = cfg.format === false
|
|
828
|
+
if (what === 'setting:lsp' && app.s) app.s.noLsp = cfg.lsp === false
|
|
829
|
+
if (what === 'setting:context') now(() => (app.s = null))
|
|
830
|
+
if (what === 'model' || what === 'setting:rotation') now(() => app.useRotation())
|
|
831
|
+
if (what === 'mcp' || what === 'skill') now(() => app.reload(what))
|
|
832
|
+
if (what === 'keys') ui.keys = { ...DEFAULT_KEYS, ...loadKeys() }
|
|
833
|
+
// a model set up or measured on the page: into the session's profile now (it saves its profile
|
|
834
|
+
// as it learns, so it mustn't keep the old one); its mode from the next message
|
|
835
|
+
if (what === 'profile' && app.s?.profile) {
|
|
836
|
+
const disk = loadProfile({ baseUrl: app.s.profile.baseUrl, model: app.s.profile.model })
|
|
837
|
+
if (disk) {
|
|
838
|
+
for (const k of ['source', 'set', 'known', 'measured', 'measuredAt']) disk[k] === undefined ? delete app.s.profile[k] : (app.s.profile[k] = disk[k])
|
|
839
|
+
const st = strategy(app.s.profile, app.cfg)
|
|
840
|
+
Object.assign(app.s.strat, (({ context, contextSure, reply }) => ({ context, contextSure, reply }))(st))
|
|
841
|
+
if (st.actions !== app.s.strat.actions || st.thinking !== app.s.strat.thinking || st.vision !== app.s.strat.vision || st.stream !== app.s.strat.stream) now(() => (app.s = null))
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
if (what === 'theme' || what === 'setting:theme') ui.recolor(before, themeCodes())
|
|
845
|
+
else ui.render()
|
|
846
|
+
}
|
|
847
|
+
apps[0]?.tabs?.onChange?.() // each tab's model, for next time
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// Changes saved by another HeartCode, or \`heart settings\` in another terminal, while this one runs:
|
|
851
|
+
// taken in as the page's own are (applySettings). config.json is compared key by key with what it
|
|
852
|
+
// was (so a model given for this run with -m stays unless the list's #1 changes); the other files
|
|
853
|
+
// by their size and time, and only when this run didn't just apply them itself.
|
|
854
|
+
const WATCHED = { 'mcp.json': 'mcp', 'keybindings.json': 'keys', 'theme.json': 'theme', models: 'profile', prompts: 'prompts', skills: 'skill' }
|
|
855
|
+
const seenStamp = {}
|
|
856
|
+
const stampOf = (p) => {
|
|
857
|
+
try {
|
|
858
|
+
const st = fs.statSync(p)
|
|
859
|
+
if (!st.isDirectory()) return `${st.mtimeMs}:${st.size}`
|
|
860
|
+
return fs.readdirSync(p, { recursive: true }).sort().map((f) => `${f}=${stampOf(path.join(p, f))}`).join('|')
|
|
861
|
+
} catch {
|
|
862
|
+
return ''
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
function settingsSeen(what) {
|
|
866
|
+
for (const [name, w] of Object.entries(WATCHED)) if (w === what || (what === 'setting:theme' && w === 'theme')) seenStamp[name] = stampOf(path.join(HOME, name))
|
|
867
|
+
}
|
|
868
|
+
function watchSettings(apps, cfg) {
|
|
869
|
+
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b)
|
|
870
|
+
let last = readJson(CONFIG_FILE, {})
|
|
871
|
+
for (const name of Object.keys(WATCHED)) seenStamp[name] = stampOf(path.join(HOME, name))
|
|
872
|
+
const configChanged = () => {
|
|
873
|
+
const now = readJson(CONFIG_FILE, null)
|
|
874
|
+
if (!now) return
|
|
875
|
+
const keys = [...new Set([...Object.keys(now), ...Object.keys(last)])].filter((k) => !same(now[k], last[k]))
|
|
876
|
+
last = now
|
|
877
|
+
const whats = new Set()
|
|
878
|
+
for (const k of keys) {
|
|
879
|
+
const value = k === 'providers' ? { ...now.providers, ...(cfg.adhoc && { [cfg.adhoc]: cfg.providers[cfg.adhoc] }) } : now[k]
|
|
880
|
+
if (same(value, cfg[k])) continue
|
|
881
|
+
value === undefined ? delete cfg[k] : (cfg[k] = value)
|
|
882
|
+
whats.add(['provider', 'model', 'providers'].includes(k) ? 'model' : k === 'rotation' ? 'setting:rotation' : `setting:${k}`)
|
|
883
|
+
}
|
|
884
|
+
for (const what of whats) applySettings(apps(), cfg, what)
|
|
885
|
+
if (whats.size) log('settings', `taken in from config.json: ${[...whats].join(', ')}`)
|
|
886
|
+
}
|
|
887
|
+
let timer = null
|
|
888
|
+
const check = () => {
|
|
889
|
+
timer = null
|
|
890
|
+
configChanged()
|
|
891
|
+
for (const [name, what] of Object.entries(WATCHED)) {
|
|
892
|
+
const now = stampOf(path.join(HOME, name))
|
|
893
|
+
if (now === seenStamp[name]) continue
|
|
894
|
+
seenStamp[name] = now
|
|
895
|
+
log('settings', `taken in: ${name} changed`)
|
|
896
|
+
applySettings(apps(), cfg, what)
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
try {
|
|
900
|
+
const watcher = fs.watch(HOME, { recursive: true }, (_, file) => {
|
|
901
|
+
const top = String(file ?? '').split(path.sep)[0]
|
|
902
|
+
if (top !== 'config.json' && !WATCHED[top]) return // sessions, logs…: not settings
|
|
903
|
+
clearTimeout(timer)
|
|
904
|
+
timer = setTimeout(check, 150) // a file written in parts, several files at once: one look
|
|
905
|
+
})
|
|
906
|
+
watcher.on('error', () => {})
|
|
907
|
+
watcher.unref()
|
|
908
|
+
} catch (e) {
|
|
909
|
+
log('settings', `can't watch ${HOME}: ${e.message}`)
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
// After /reset: every tab goes on with what's left (the files as they are now), from the next
|
|
914
|
+
// message; the screen in the theme there is now.
|
|
915
|
+
function afterReset(apps, level) {
|
|
916
|
+
const fresh = loadConfig()
|
|
917
|
+
useRequestLog(fresh)
|
|
918
|
+
const before = themeCodes()
|
|
919
|
+
setTheme(fresh.theme)
|
|
920
|
+
reloadPrompts()
|
|
921
|
+
for (const app of apps) {
|
|
922
|
+
for (const k of Object.keys(app.cfg)) if (!['force', 'maxRetries', 'maxTurns', 'mcp', 'adhoc', 'fixed'].includes(k)) delete app.cfg[k]
|
|
923
|
+
Object.assign(app.cfg, fresh)
|
|
924
|
+
const { ui } = app
|
|
925
|
+
ui.permission = fresh.permissions ?? 'all' // not setPermission: that saves it again
|
|
926
|
+
ui.keys = { ...DEFAULT_KEYS, ...loadKeys() }
|
|
927
|
+
app.lists = {}
|
|
928
|
+
const now = (fn) => (ui.busySince ? app.later.push(fn) : fn())
|
|
929
|
+
now(() => {
|
|
930
|
+
app.s = null
|
|
931
|
+
app.reload('mcp')
|
|
932
|
+
app.reload('skill')
|
|
933
|
+
if (level === 'everything') app.memory = new Memory(app.root)
|
|
934
|
+
})
|
|
935
|
+
ui.recolor(before, themeCodes())
|
|
936
|
+
}
|
|
937
|
+
apps[0]?.tabs?.onChange?.()
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
// A tab in a worktree closed while other tabs stay open: removed if nothing changed, else kept.
|
|
941
|
+
function leaveTabWorktree(app) {
|
|
942
|
+
const wt = app.worktree
|
|
943
|
+
if (!wt) return
|
|
944
|
+
wt.done = true
|
|
945
|
+
const ui = app.tabs.at.ui
|
|
946
|
+
if (worktreeState(wt).empty) return removeWorktree(wt), ui.print(c.dim(`worktree ${wt.name}: nothing changed, removed`))
|
|
947
|
+
ui.print(`${c.dim('kept worktree')} ${c.info(wt.dir)} ${c.dim('on')} ${c.info(wt.branch)} ${c.faint(`· merge: git merge ${wt.branch} (commit in the worktree first)`)}`)
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
// The system clipboard (pbcopy, clip, wl-copy, xclip, xsel); else the terminal's (OSC 52, works over SSH too).
|
|
951
|
+
function copyText(text) {
|
|
952
|
+
const tools = process.platform === 'darwin' ? [['pbcopy']] : process.platform === 'win32' ? [['clip']] : [['wl-copy'], ['xclip', '-selection', 'clipboard'], ['xsel', '-b', '-i']]
|
|
953
|
+
for (const [cmd, ...args] of tools) if (spawnSync(cmd, args, { input: text, stdio: ['pipe', 'ignore', 'ignore'] }).status === 0) return 'system'
|
|
954
|
+
process.stdout.write(`\x1b]52;c;${Buffer.from(text).toString('base64')}\x07`)
|
|
955
|
+
return 'osc'
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
// A model's profile: the saved one; else what models.dev says (nothing is sent to the model to
|
|
959
|
+
// find out); a model it doesn't know: plain assumptions, until you set it up on the settings page.
|
|
960
|
+
// force (/test_model, heart probe): test it now instead, with real requests.
|
|
961
|
+
async function getProfile(client, ui, force, signal) {
|
|
962
|
+
let p = !force && loadProfile(client)
|
|
963
|
+
if (!p && !force) {
|
|
964
|
+
await loadPrices()
|
|
965
|
+
p = profileFrom(client, limitsOf(client.provider, client.baseUrl, client.model))
|
|
966
|
+
writeJson(profilePath(client), p)
|
|
967
|
+
}
|
|
968
|
+
// one models.dev didn't know when it was saved: looked up again (it may have been added, or the list
|
|
969
|
+
// couldn't be had then)
|
|
970
|
+
if (p?.source === 'assumed') {
|
|
971
|
+
await loadPrices()
|
|
972
|
+
const known = limitsOf(client.provider, client.baseUrl, client.model)
|
|
973
|
+
if (known) writeJson(profilePath(client), (p = { ...p, known, source: 'models.dev' }))
|
|
974
|
+
}
|
|
975
|
+
if (p) {
|
|
976
|
+
if (p.source === 'assumed') ui.warn(`${client.model} isn't in models.dev: assuming it calls tools and streams, with a ~128k window. Set what it can do on /settings`)
|
|
977
|
+
return p
|
|
978
|
+
}
|
|
979
|
+
ui.print(c.dim(`Testing ${client.model}…`))
|
|
980
|
+
const t0 = Date.now()
|
|
981
|
+
const tested = await probe(client, (label) => ui.spinner(`testing: ${label}`), signal)
|
|
982
|
+
ui.stopSpin()
|
|
983
|
+
ui.print(c.dim(`done in ${((Date.now() - t0) / 1000).toFixed(1)}s · saved to ${profilePath(client)}`))
|
|
984
|
+
return tested
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
// A setting from its choices: the argument if valid, else a picker. Returns the choice or null.
|
|
988
|
+
async function choose(ui, cmd, arg, title, current) {
|
|
989
|
+
if (ARGS[cmd].some(([o]) => o === arg)) return arg
|
|
990
|
+
return (await ui.pick(title, ARGS[cmd].map(([label, desc]) => ({ label, desc, current: label === current }))))?.label ?? null
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
async function slash(app, line) {
|
|
994
|
+
const [cmd, ...args] = line.slice(1).trim().split(/\s+/)
|
|
995
|
+
const { ui, cfg } = app
|
|
996
|
+
switch (cmd) {
|
|
997
|
+
case 'exit':
|
|
998
|
+
case 'quit': {
|
|
999
|
+
const n = app.tabs?.list.length ?? 1
|
|
1000
|
+
if (n > 1) {
|
|
1001
|
+
const go = await ui.pick(`Quit HeartCode? ${n} tabs are open`, [
|
|
1002
|
+
{ label: 'Yes, quit', desc: 'they come back next time you start HeartCode here', yes: true },
|
|
1003
|
+
{ label: 'No', desc: 'Ctrl+Q closes just this tab' },
|
|
1004
|
+
])
|
|
1005
|
+
if (!go?.yes) return
|
|
1006
|
+
}
|
|
1007
|
+
return 'exit'
|
|
1008
|
+
}
|
|
1009
|
+
case 'reset': {
|
|
1010
|
+
const it = await ui.pick('Reset what?', RESETS.map((r) => ({ label: r.label, desc: r.desc, level: r.level })))
|
|
1011
|
+
if (!it) return ui.print(c.dim('nothing reset'))
|
|
1012
|
+
const home = process.env.HOME
|
|
1013
|
+
const tilde = (f) => (home && f.startsWith(home) ? '~' + f.slice(home.length) : f)
|
|
1014
|
+
const files = resetPaths(it.level)
|
|
1015
|
+
const what = [...(it.level === 'settings' ? [`the settings in ${tilde(CONFIG_FILE)} (not your providers, keys or model)`] : []), ...files.map(tilde)]
|
|
1016
|
+
ui.print(`${c.warn('!')} ${c.bold(`Reset: ${it.label.toLowerCase()}.`)} ${c.muted("This can't be undone. It removes:")}\n${what.length ? what.map((f) => ` ${c.faint('·')} ${f}`).join('\n') : c.faint(' (nothing there yet)')}`)
|
|
1017
|
+
const code = resetCode()
|
|
1018
|
+
const typed = await ui.ask(` Type ${c.bold(code)} to reset ${c.dim('(anything else: no)')}: `)
|
|
1019
|
+
if (typed?.trim().toUpperCase() !== code) return ui.print(c.dim('not reset'))
|
|
1020
|
+
reset(it.level)
|
|
1021
|
+
log('reset', it.level, what)
|
|
1022
|
+
afterReset(app.tabs ? app.tabs.list.map((t) => t.app) : [app], it.level)
|
|
1023
|
+
return ui.print(`${c.ok('✓')} ${c.muted(`reset: ${it.label.toLowerCase()}`)}${it.level === 'settings' ? '' : c.dim(' · add a provider on /settings')}`)
|
|
1024
|
+
}
|
|
1025
|
+
case 'settings': {
|
|
1026
|
+
settingsPage ??= startSettings({ cfg, apply: (what) => applySettings(app.tabs ? app.tabs.list.map((t) => t.app) : [app], cfg, what) })
|
|
1027
|
+
const { url, close } = await settingsPage
|
|
1028
|
+
openBrowser(url)
|
|
1029
|
+
ui.print(`${c.info('↗')} ${c.bold('settings')} ${c.muted('opened in your browser:')}\n ${c.faint(url)}`)
|
|
1030
|
+
// a dialog until it's turned off: nothing else meanwhile, in any tab (Esc turns it off too).
|
|
1031
|
+
// Not busy meanwhile: what's changed on the page applies at once, here too.
|
|
1032
|
+
ui.idle()
|
|
1033
|
+
if (app.tabs) app.tabs.locked = true
|
|
1034
|
+
try {
|
|
1035
|
+
await ui.pick(`${c.info('↗')} ${c.bold('The settings page is on')}`, [{ label: 'Turn off', desc: 'stops the page and gives HeartCode back' }], { fixed: true })
|
|
1036
|
+
} finally {
|
|
1037
|
+
if (app.tabs) app.tabs.locked = false
|
|
1038
|
+
close()
|
|
1039
|
+
settingsPage = null
|
|
1040
|
+
}
|
|
1041
|
+
return ui.print(c.dim('settings page turned off'))
|
|
1042
|
+
}
|
|
1043
|
+
case 'tab': {
|
|
1044
|
+
const { tabs } = app
|
|
1045
|
+
if (!tabs) return ui.print(c.dim('tabs need the full screen'))
|
|
1046
|
+
if (!args[0] || args[0] === 'new') return void tabs.add({ root: app.root })
|
|
1047
|
+
if (args[0] === 'worktree') {
|
|
1048
|
+
const wt = createWorktree(app.root, args[1] ?? '')
|
|
1049
|
+
return void tabs.add({ root: wt.cwd, wt })
|
|
1050
|
+
}
|
|
1051
|
+
if (/^\d+$/.test(args[0]) && tabs.list[args[0] - 1]) return tabs.show(tabs.list[args[0] - 1])
|
|
1052
|
+
return ui.print(`${c.cyan('/tab')} ${c.faint('a new tab ·')} ${c.cyan('/tab worktree')} ${c.muted('[name]')} ${c.faint('a new tab in its own git worktree ·')} ${c.cyan('/tab')} ${c.muted('<1–' + tabs.list.length + '>')} ${c.faint('go to that tab')}`)
|
|
1053
|
+
}
|
|
1054
|
+
case 'help':
|
|
1055
|
+
return ui.print(app.help())
|
|
1056
|
+
case 'select_model':
|
|
1057
|
+
return app.pickModel(args)
|
|
1058
|
+
case 'rename': {
|
|
1059
|
+
const name = args.join(' ').trim() || (await ui.ask(` ${c.bold('Name for this session')}: `, { initial: app.chat.name ?? '' }))?.trim()
|
|
1060
|
+
if (!name) return ui.print(c.dim('cancelled'))
|
|
1061
|
+
app.chat.name = name
|
|
1062
|
+
if (app.chat.turns.length) app.save()
|
|
1063
|
+
return ui.print(c.dim(`named "${name}"${app.chat.turns.length ? '' : ' (saved with the first message)'}`))
|
|
1064
|
+
}
|
|
1065
|
+
case 'fork': {
|
|
1066
|
+
if (!app.chat.turns.length) return ui.print('Nothing to fork yet.')
|
|
1067
|
+
app.save()
|
|
1068
|
+
const was = app.chat.id
|
|
1069
|
+
app.setChat(forkChat(app.chat))
|
|
1070
|
+
app.save()
|
|
1071
|
+
return ui.print(c.dim(`forked: you're in "${app.chat.name}" now; the original (${was}) stays in /resume`))
|
|
1072
|
+
}
|
|
1073
|
+
case 'export': {
|
|
1074
|
+
if (!app.chat.turns.length) return ui.print('Nothing to export yet.')
|
|
1075
|
+
let file = args.join(' ').trim()
|
|
1076
|
+
if (!file) file = (await choose(ui, cmd, '', 'Export as?')) ?? ''
|
|
1077
|
+
if (!file) return
|
|
1078
|
+
if (ARGS.export.some(([f]) => f === file)) file = `heartcode-${app.chat.id}.${file}`
|
|
1079
|
+
const format = /\.html?$/i.test(file) ? 'html' : /\.json$/i.test(file) ? 'json' : 'md'
|
|
1080
|
+
const abs = path.resolve(app.root, file)
|
|
1081
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true })
|
|
1082
|
+
fs.writeFileSync(abs, exportChat(app.chat, format, cfg.model ? `${cfg.provider} · ${cfg.model}` : ''))
|
|
1083
|
+
return ui.print(`${c.green('saved')} ${path.relative(app.root, abs) || abs}`)
|
|
1084
|
+
}
|
|
1085
|
+
case 'diff': {
|
|
1086
|
+
// each file from before HeartCode first changed it in this session, to now
|
|
1087
|
+
const first = new Map()
|
|
1088
|
+
for (const t of app.chat.turns) for (const [rel, before] of Object.entries(t.files)) if (!first.has(rel)) first.set(rel, before)
|
|
1089
|
+
const want = args.join(' ').trim()
|
|
1090
|
+
const files = [...first.keys()].filter((f) => !want || f.includes(want)).sort()
|
|
1091
|
+
if (!files.length) return ui.print(want ? `No changes to ${want} in this session.` : 'No changes in this session yet.')
|
|
1092
|
+
let shown = 0
|
|
1093
|
+
for (const rel of files) {
|
|
1094
|
+
const abs = path.join(app.root, rel)
|
|
1095
|
+
const now = fs.existsSync(abs) ? fs.readFileSync(abs, 'utf8') : null
|
|
1096
|
+
const was = first.get(rel)
|
|
1097
|
+
if (was === now) continue
|
|
1098
|
+
shown++
|
|
1099
|
+
if (was === null || now === null) ui.print(`${c.bold(rel)} ${was === null ? c.green('(new)') : c.red('(deleted)')}`)
|
|
1100
|
+
if (now !== null) ui.print(renderDiff(diffLines(was ?? '', now), rel, undefined, 3, 400))
|
|
1101
|
+
}
|
|
1102
|
+
return ui.print(c.dim(shown ? `${shown} file${shown > 1 ? 's' : ''} changed in this session${files.length > shown ? ` (${files.length - shown} back as they were)` : ''}` : 'every file HeartCode changed is back as it was'))
|
|
1103
|
+
}
|
|
1104
|
+
case 'init': {
|
|
1105
|
+
const has = (f) => fs.existsSync(path.join(app.root, f))
|
|
1106
|
+
const file = has('HEARTCODE.md') ? 'HEARTCODE.md' : 'AGENTS.md'
|
|
1107
|
+
const others = ['AGENTS.md', 'CLAUDE.md', 'GEMINI.md', '.github/copilot-instructions.md', '.cursorrules'].filter((f) => f !== file && has(f))
|
|
1108
|
+
return runTask(app, prompt('agent/init', { file, existing: has(file), others: others.join(', ') }), { signal: app.signal })
|
|
1109
|
+
}
|
|
1110
|
+
case 'copy': {
|
|
1111
|
+
const answer = app.chat.history.at(-1)?.answer?.trim()
|
|
1112
|
+
if (!answer) return ui.print('No answer to copy yet.')
|
|
1113
|
+
const how = copyText(answer)
|
|
1114
|
+
return ui.print(c.dim(`copied the last answer (${answer.split('\n').length} lines)${how === 'osc' ? ' through the terminal' : ''}`))
|
|
1115
|
+
}
|
|
1116
|
+
case 'cost': {
|
|
1117
|
+
const u = app.chat.usage
|
|
1118
|
+
if (!u?.requests) return ui.print('Nothing used yet in this session.')
|
|
1119
|
+
const price = app.s?.client.price?.()
|
|
1120
|
+
ui.print(`${c.bold('tokens')} ${kTokens(u.input)} in${u.cached ? ` (${kTokens(u.cached)} from the cache)` : ''} · ${kTokens(u.output)} out · ${u.requests} request${u.requests > 1 ? 's' : ''}`)
|
|
1121
|
+
return ui.print(
|
|
1122
|
+
u.priced
|
|
1123
|
+
? `${c.bold('cost')} ${money(u.cost)}${price ? c.faint(` (${cfg.model}: $${price.input ?? 0} in / $${price.output ?? 0} out per million tokens, from models.dev)`) : ''}`
|
|
1124
|
+
: c.faint(`cost: not known for ${cfg.model ?? 'this model'} (models.dev doesn't list it; local and subscription models have none)`),
|
|
1125
|
+
)
|
|
1126
|
+
}
|
|
1127
|
+
case 'clear_chat': // a new session; the old one stays in /resume
|
|
1128
|
+
ui.clear()
|
|
1129
|
+
app.setChat(newChat(app.root))
|
|
1130
|
+
if (app.s) app.s.client.lastTokens = 0
|
|
1131
|
+
return
|
|
1132
|
+
case 'resume':
|
|
1133
|
+
return app.resume()
|
|
1134
|
+
case 'rewind':
|
|
1135
|
+
return app.rewind()
|
|
1136
|
+
case 'tree':
|
|
1137
|
+
return app.tree()
|
|
1138
|
+
case 'tidy_memory': {
|
|
1139
|
+
const s = await app.session()
|
|
1140
|
+
if (!s) return
|
|
1141
|
+
return s.tidyMemory(app.signal)
|
|
1142
|
+
}
|
|
1143
|
+
case 'login_mcp': {
|
|
1144
|
+
const remote = app.mcp.names.filter((n) => app.mcp.get(n).url)
|
|
1145
|
+
const name = args[0] ?? (await ui.pick('Sign in to which server?', remote.map((n) => ({ label: n, desc: app.mcp.get(n).url }))))?.label
|
|
1146
|
+
if (!name) return remote.length ? undefined : ui.print('No remote MCP servers (ones with a "url") to sign in to.')
|
|
1147
|
+
const sv = app.mcp.get(name)
|
|
1148
|
+
if (!sv?.url) return ui.print(`No remote MCP server "${name}".`)
|
|
1149
|
+
forget(sv.url) // a fresh sign-in
|
|
1150
|
+
sv.stop()
|
|
1151
|
+
Object.assign(sv, { token: null, tools: null })
|
|
1152
|
+
ui.spinner(`connecting to ${name}`)
|
|
1153
|
+
const tools = await sv.listTools(app.signal)
|
|
1154
|
+
ui.stopSpin()
|
|
1155
|
+
return ui.print(`${c.green('connected')} ${c.bold(name)} · ${tools.length} tools`)
|
|
1156
|
+
}
|
|
1157
|
+
case 'import': {
|
|
1158
|
+
const items = findImports(app.root)
|
|
1159
|
+
if (!items.length) return ui.print('Nothing to import: no commands, skills, MCP servers, instructions or memory from Claude Code, Codex, Gemini CLI, OpenCode or Cursor found.')
|
|
1160
|
+
const chosen = await ui.pick(`Import from other CLIs (${items.length} found)`, items, { multi: true })
|
|
1161
|
+
if (!chosen) return
|
|
1162
|
+
for (const it of chosen) {
|
|
1163
|
+
try {
|
|
1164
|
+
ui.print(`${c.green('✓')} ${it.run()}`)
|
|
1165
|
+
} catch (e) {
|
|
1166
|
+
ui.print(`${c.red('✗')} ${it.label}: ${e.message}`)
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
app.reload('skill')
|
|
1170
|
+
app.reload('mcp')
|
|
1171
|
+
log('import', chosen.map((it) => `${it.group}: ${it.label}`))
|
|
1172
|
+
return
|
|
1173
|
+
}
|
|
1174
|
+
case 'btw':
|
|
1175
|
+
return app.btw(line.trim().replace(/^\/btw\b/, '').trim())
|
|
1176
|
+
case 'voice': {
|
|
1177
|
+
const text = await ui.dictate()
|
|
1178
|
+
if (text) app.nextInput = text // in the box, to read over and send
|
|
1179
|
+
return
|
|
1180
|
+
}
|
|
1181
|
+
case 'toggle_thinking':
|
|
1182
|
+
ui.showThinking = !ui.showThinking
|
|
1183
|
+
return ui.print(c.dim(`thinking ${ui.showThinking ? 'shown' : 'collapsed'}`))
|
|
1184
|
+
}
|
|
1185
|
+
if (!['model_info', 'test_model', 'compact'].includes(cmd)) {
|
|
1186
|
+
app.skills = loadSkills(app.root) // edits to skill files apply right away
|
|
1187
|
+
if (app.s) app.s.skills = app.skills
|
|
1188
|
+
const k = app.skills.get(cmd)
|
|
1189
|
+
if (k && k.use !== 'model') return runTask(app, await app.expandSkill(k, args.join(' ')), { signal: app.signal })
|
|
1190
|
+
return ui.print(`unknown command /${cmd} ${c.dim('· /help lists them')}`)
|
|
1191
|
+
}
|
|
1192
|
+
const s = await app.session()
|
|
1193
|
+
if (!s) return
|
|
1194
|
+
const restrat = () => (s.strat = strategy(s.profile, cfg))
|
|
1195
|
+
switch (cmd) {
|
|
1196
|
+
case 'compact': {
|
|
1197
|
+
const done = await s.compact({ signal: app.signal, focus: args.join(' ') })
|
|
1198
|
+
if (!done) return ui.print('Nothing to compact yet.')
|
|
1199
|
+
s.client.lastTokens = 0
|
|
1200
|
+
return app.save()
|
|
1201
|
+
}
|
|
1202
|
+
case 'model_info':
|
|
1203
|
+
return ui.print(`${c.cyan(s.client.model)}\n${describe(s.profile, s.strat)}`)
|
|
1204
|
+
case 'test_model':
|
|
1205
|
+
s.profile = await getProfile(s.client, ui, true, app.signal)
|
|
1206
|
+
restrat()
|
|
1207
|
+
s.reset()
|
|
1208
|
+
return ui.print(describe(s.profile, s.strat))
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
async function runTask(app, task, ctl) {
|
|
1213
|
+
const { ui } = app
|
|
1214
|
+
try {
|
|
1215
|
+
const s = await app.session()
|
|
1216
|
+
if (!s) return false
|
|
1217
|
+
try {
|
|
1218
|
+
await s.run(task, ctl?.signal)
|
|
1219
|
+
} finally {
|
|
1220
|
+
app.save()
|
|
1221
|
+
}
|
|
1222
|
+
} catch (e) {
|
|
1223
|
+
ui.stopSpin()
|
|
1224
|
+
log('error', e.name === 'AbortError' || ctl?.signal.aborted ? 'task interrupted by the user' : e.stack)
|
|
1225
|
+
if (e.name === 'AbortError' || ctl?.signal.aborted) ui.print(c.yellow('\ninterrupted'))
|
|
1226
|
+
else ui.print(c.red(`\nerror: ${e.message}`))
|
|
1227
|
+
return false
|
|
1228
|
+
}
|
|
1229
|
+
return true
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
async function repl(app, f = {}) {
|
|
1233
|
+
const { ui, cfg } = app
|
|
1234
|
+
ui.fullscreen()
|
|
1235
|
+
ui.onDoubleEscape = () => '/rewind'
|
|
1236
|
+
let ctl = null
|
|
1237
|
+
ui.onEscape = () => ctl?.abort()
|
|
1238
|
+
ui.onBtw = (q) => app.btw(q, true) // /btw typed while it works
|
|
1239
|
+
ui.voiceConfig = () => ({ voice: cfg.voice, providers: cfg.providers })
|
|
1240
|
+
// Ctrl+C: stops what's running; clears the input if there's text; otherwise asks before quitting
|
|
1241
|
+
// (a second Ctrl+C within 2s quits, also when something is stuck).
|
|
1242
|
+
let lastInterrupt = 0
|
|
1243
|
+
// Ctrl+C twice: all of HeartCode (its tabs come back next time, in continueTabs)
|
|
1244
|
+
const quit = () => {
|
|
1245
|
+
if (app.tabs) app.tabs.quitting = true
|
|
1246
|
+
ui.print()
|
|
1247
|
+
ui.close()
|
|
1248
|
+
process.exit(0)
|
|
1249
|
+
}
|
|
1250
|
+
// Ctrl+Q: this tab (working: press it again to stop the work too); the last one quits
|
|
1251
|
+
let lastClose = 0
|
|
1252
|
+
ui.onCloseTab = () => {
|
|
1253
|
+
if (ctl && Date.now() - lastClose > 2000) {
|
|
1254
|
+
lastClose = Date.now()
|
|
1255
|
+
return ui.notice('Still working: Ctrl+Q again stops it and closes this tab', 2000)
|
|
1256
|
+
}
|
|
1257
|
+
if (app.tabs.list.length < 2) app.tabs.quitting = true
|
|
1258
|
+
ctl?.abort()
|
|
1259
|
+
ui.cancelAsk()
|
|
1260
|
+
ui.close()
|
|
1261
|
+
}
|
|
1262
|
+
const others = () => (app.tabs?.list.length ?? 1) - 1
|
|
1263
|
+
ui.onInterrupt = () => {
|
|
1264
|
+
const again = Date.now() - lastInterrupt < 2000
|
|
1265
|
+
lastInterrupt = Date.now()
|
|
1266
|
+
if (ctl) {
|
|
1267
|
+
if (again) return quit()
|
|
1268
|
+
ctl.abort()
|
|
1269
|
+
ui.cancelAsk()
|
|
1270
|
+
return ui.warn(`stopping… (Ctrl+C again to quit${others() ? ` HeartCode, all ${others() + 1} tabs` : ''})`)
|
|
1271
|
+
}
|
|
1272
|
+
if (ui.rl?.line) {
|
|
1273
|
+
lastInterrupt = 0 // clearing isn't the first half of quitting
|
|
1274
|
+
return ui.clearInput()
|
|
1275
|
+
}
|
|
1276
|
+
if (again) return quit() // the second within 2s (also when the first stopped the work)
|
|
1277
|
+
ui.notice(others() ? `Press Ctrl+C again to quit HeartCode (all ${others() + 1} tabs; Ctrl+Q closes just this one)` : 'Press Ctrl+C again to quit', 2000)
|
|
1278
|
+
}
|
|
1279
|
+
// Pinned to the top of the screen; /clear_chat and scrolling leave it alone.
|
|
1280
|
+
const home = process.env.HOME
|
|
1281
|
+
const where = home && app.root.startsWith(home) ? '~' + app.root.slice(home.length) : app.root
|
|
1282
|
+
ui.header = (cols = 100) => [
|
|
1283
|
+
`${c.brand('♥')} ${c.bright(c.bold('HeartCode'))} ${c.faint(VERSION)} ${c.muted(where.length > cols - 16 ? '…' + where.slice(-(cols - 17)) : where)}`,
|
|
1284
|
+
[['/', 'commands'], ['@', 'files'], [NEWLINE_KEY, 'new line'], ['Esc', 'stop'], ['Shift+Tab', 'permissions'], ['Ctrl+R', 'voice']].map(([k, d]) => `${c.muted(k)} ${c.faint(d)}`).join(c.ghost(' · ')),
|
|
1285
|
+
'',
|
|
1286
|
+
]
|
|
1287
|
+
if (!ui.full) ui.print(ui.header().join('\n'))
|
|
1288
|
+
if (f.onStart) await f.onStart(app)
|
|
1289
|
+
if (f.continue) {
|
|
1290
|
+
const last = listChats(app.root)[0]
|
|
1291
|
+
if (last) await app.resume(last)
|
|
1292
|
+
else ui.print(c.dim('No earlier session in this folder to continue.'))
|
|
1293
|
+
}
|
|
1294
|
+
if (f['resume-id']) await app.resume(loadChat(f['resume-id']))
|
|
1295
|
+
else if (f.resume) await app.resume()
|
|
1296
|
+
if (f.model) useModel(app, f.model) // a continued tab's own model
|
|
1297
|
+
app.tabs?.onChange?.() // the conversation it opened with
|
|
1298
|
+
for (;;) {
|
|
1299
|
+
const line = await ui.ask(` ${c.magenta('♥')} `, { suggest: true, initial: app.nextInput, history: true })
|
|
1300
|
+
app.nextInput = undefined
|
|
1301
|
+
if (line === null) break
|
|
1302
|
+
const t = line.trim()
|
|
1303
|
+
if (!t) continue
|
|
1304
|
+
ctl = new AbortController()
|
|
1305
|
+
app.signal = ctl.signal // Esc stops whatever this line started (a task, a probe, …)
|
|
1306
|
+
ui.busy() // the spinner stays until this line's work is done
|
|
1307
|
+
if (t.startsWith('/')) {
|
|
1308
|
+
try {
|
|
1309
|
+
if ((await slash(app, t)) === 'exit') {
|
|
1310
|
+
if (app.tabs) app.tabs.quitting = true
|
|
1311
|
+
break
|
|
1312
|
+
}
|
|
1313
|
+
} catch (e) {
|
|
1314
|
+
ui.stopSpin()
|
|
1315
|
+
log('error', e.stack)
|
|
1316
|
+
ui.print(c.red(`error: ${e.message}`))
|
|
1317
|
+
}
|
|
1318
|
+
} else {
|
|
1319
|
+
let ok = await runTask(app, t, ctl)
|
|
1320
|
+
// plan mode: the plan is done; go ahead with it?
|
|
1321
|
+
while (ok && ui.permission === 'plan' && !ctl.signal.aborted) {
|
|
1322
|
+
const back = ui.beforePlan && ui.beforePlan !== 'plan' ? ui.beforePlan : 'all'
|
|
1323
|
+
const go = await ui.pick('Go ahead with this plan?', [
|
|
1324
|
+
{ label: 'Yes', desc: Object.fromEntries(PERMISSIONS)[back], mode: back },
|
|
1325
|
+
...(back !== 'ask' ? [{ label: 'Yes, and ask before every change', mode: 'ask' }] : []),
|
|
1326
|
+
{ label: 'No, keep planning', desc: 'then type what to change in the plan' },
|
|
1327
|
+
])
|
|
1328
|
+
if (!go?.mode) break
|
|
1329
|
+
ui.setPermission(go.mode)
|
|
1330
|
+
ok = await runTask(app, prompt('agent/plan-approved'), ctl)
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
ui.idle()
|
|
1334
|
+
ctl = null
|
|
1335
|
+
app.afterLine()
|
|
1336
|
+
app.tabs?.onChange?.() // the conversation may be another now (/resume, /fork, /clear_chat)
|
|
1337
|
+
if (ui.tail) ui.print() // nothing after /clear_chat
|
|
1338
|
+
}
|
|
1339
|
+
ui.close()
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
async function main() {
|
|
1343
|
+
// "--resume <id>" (as in Claude Code) vs a bare "--resume" (pick one): an id follows it
|
|
1344
|
+
const argv = process.argv.slice(2)
|
|
1345
|
+
const print = argv.includes('-p') || argv.includes('--print') // headless: an id after --resume is always an id
|
|
1346
|
+
for (let i = 0; i < argv.length; i++) if (argv[i] === '-w' || argv[i] === '--worktree') argv[i] = '--worktree=' // a name only as --worktree=name
|
|
1347
|
+
for (let i = 0; i < argv.length; i++) if ((argv[i] === '--resume' || argv[i] === '-r') && /^[\w.-]{6,}$/.test(argv[i + 1] ?? '') && (print || loadChat(argv[i + 1]))) argv[i] = '--resume-id'
|
|
1348
|
+
const { values: f, positionals } = parseArgs({
|
|
1349
|
+
args: argv,
|
|
1350
|
+
allowPositionals: true,
|
|
1351
|
+
options: {
|
|
1352
|
+
'base-url': { type: 'string', short: 'u' },
|
|
1353
|
+
model: { type: 'string', short: 'm' },
|
|
1354
|
+
'api-key': { type: 'string' },
|
|
1355
|
+
yes: { type: 'boolean', short: 'y' },
|
|
1356
|
+
ask: { type: 'boolean' },
|
|
1357
|
+
'hide-thinking': { type: 'boolean' },
|
|
1358
|
+
cwd: { type: 'string', short: 'C' },
|
|
1359
|
+
context: { type: 'string' },
|
|
1360
|
+
force: { type: 'boolean' },
|
|
1361
|
+
save: { type: 'boolean' },
|
|
1362
|
+
continue: { type: 'boolean', short: 'c' },
|
|
1363
|
+
resume: { type: 'boolean', short: 'r' },
|
|
1364
|
+
'resume-id': { type: 'string' },
|
|
1365
|
+
worktree: { type: 'string' },
|
|
1366
|
+
acp: { type: 'boolean' },
|
|
1367
|
+
provider: { type: 'string' },
|
|
1368
|
+
print: { type: 'boolean', short: 'p' },
|
|
1369
|
+
'output-format': { type: 'string' },
|
|
1370
|
+
'input-format': { type: 'string' },
|
|
1371
|
+
'append-system-prompt': { type: 'string' },
|
|
1372
|
+
'mcp-config': { type: 'string', multiple: true },
|
|
1373
|
+
'strict-mcp-config': { type: 'boolean' },
|
|
1374
|
+
'skills-dir': { type: 'string', multiple: true },
|
|
1375
|
+
allowedTools: { type: 'string', multiple: true },
|
|
1376
|
+
'allowed-tools': { type: 'string', multiple: true },
|
|
1377
|
+
disallowedTools: { type: 'string', multiple: true },
|
|
1378
|
+
'disallowed-tools': { type: 'string', multiple: true },
|
|
1379
|
+
'permission-mode': { type: 'string' },
|
|
1380
|
+
'dangerously-skip-permissions': { type: 'boolean' },
|
|
1381
|
+
'session-id': { type: 'string' },
|
|
1382
|
+
effort: { type: 'string' },
|
|
1383
|
+
'no-memory': { type: 'boolean' },
|
|
1384
|
+
verbose: { type: 'boolean' },
|
|
1385
|
+
'max-retries': { type: 'string' },
|
|
1386
|
+
'max-turns': { type: 'string' },
|
|
1387
|
+
all: { type: 'boolean' }, // heart models --all
|
|
1388
|
+
json: { type: 'boolean' }, // heart models --json
|
|
1389
|
+
// accepted for drop-in use where Claude Code is expected; nothing to do in HeartCode
|
|
1390
|
+
autocompact: { type: 'string' },
|
|
1391
|
+
'exclude-dynamic-system-prompt-sections': { type: 'boolean' },
|
|
1392
|
+
'include-partial-messages': { type: 'boolean' },
|
|
1393
|
+
help: { type: 'boolean', short: 'h' },
|
|
1394
|
+
version: { type: 'boolean', short: 'v' },
|
|
1395
|
+
},
|
|
1396
|
+
})
|
|
1397
|
+
if (f.help) return console.log(HELP)
|
|
1398
|
+
if (f.version) return console.log(VERSION)
|
|
1399
|
+
if (f.context && !(Number(f.context) > 0)) throw new Error('--context must be a number of tokens')
|
|
1400
|
+
for (const k of ['output-format', 'input-format']) if (f[k] && !['text', 'json', 'stream-json'].includes(f[k])) throw new Error(`--${k} must be text, json or stream-json`)
|
|
1401
|
+
if (f['permission-mode'] && !PERMISSION_MODES[f['permission-mode']]) throw new Error(`--permission-mode must be one of ${Object.keys(PERMISSION_MODES).join(', ')}`)
|
|
1402
|
+
|
|
1403
|
+
const cfg = loadConfig({ baseUrl: f['base-url'], model: f.model, apiKey: f['api-key'], context: f.context })
|
|
1404
|
+
setTheme(cfg.theme)
|
|
1405
|
+
useLogs(cfg)
|
|
1406
|
+
const safe = { ...cfg, providers: Object.fromEntries(Object.entries(cfg.providers).map(([n, p]) => [n, { ...p, apiKey: p.apiKey && mask(p.apiKey) }])) }
|
|
1407
|
+
log('start', `heartcode ${VERSION} · node ${process.version} · ${process.platform} · cwd ${process.cwd()}`, { args: process.argv.slice(2).map((a) => (a === f['api-key'] ? mask(a) : a)), config: safe })
|
|
1408
|
+
cfg.force = f.force
|
|
1409
|
+
if (f.provider) {
|
|
1410
|
+
if (!cfg.providers[f.provider]) throw new Error(`no provider "${f.provider}" (heartcode providers lists them)`)
|
|
1411
|
+
Object.assign(cfg, { provider: f.provider, fixed: true })
|
|
1412
|
+
}
|
|
1413
|
+
const [p0, ...rest] = String(f.model ?? '').split(/\s+/) // -m "groq openai/gpt-oss-120b"
|
|
1414
|
+
if (rest.length && cfg.providers[p0]) Object.assign(cfg, { provider: p0, model: rest.join(' ') })
|
|
1415
|
+
if (f.effort) cfg.effort = f.effort === 'default' ? undefined : f.effort
|
|
1416
|
+
if (f['mcp-config'] || f['strict-mcp-config']) cfg.mcp = { extra: f['mcp-config'] ?? [], strict: Boolean(f['strict-mcp-config']) }
|
|
1417
|
+
extraSkillDirs.push(...(f['skills-dir'] ?? []).map((d) => path.resolve(d)))
|
|
1418
|
+
if (f.save) {
|
|
1419
|
+
cfg.adhoc = null
|
|
1420
|
+
saveConfig(cfg)
|
|
1421
|
+
console.log(c.dim(`saved to ${CONFIG_FILE}`))
|
|
1422
|
+
}
|
|
1423
|
+
const sub = ['probe', 'models', 'providers', 'config', 'settings'].includes(positionals[0]) && positionals.length <= 2 ? positionals[0] : null
|
|
1424
|
+
|
|
1425
|
+
if (sub === 'settings') {
|
|
1426
|
+
const { url } = await startSettings({ cfg })
|
|
1427
|
+
console.log(`${c.brand('♥')} HeartCode settings: ${url}\n${c.dim('opened in your browser · Ctrl+C to stop')}`)
|
|
1428
|
+
openBrowser(url)
|
|
1429
|
+
return new Promise(() => {}) // until Ctrl+C
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
if (sub === 'config') {
|
|
1433
|
+
const file = readJson(CONFIG_FILE, {})
|
|
1434
|
+
for (const p of Object.values(file.providers ?? {})) if (p.apiKey) p.apiKey = mask(p.apiKey)
|
|
1435
|
+
return console.log(`${CONFIG_FILE}\n${JSON.stringify(file, null, 2)}`)
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
if (f.acp) {
|
|
1439
|
+
// each editor session gets its own App, for its folder and its MCP servers
|
|
1440
|
+
cfg.maxRetries ??= 10
|
|
1441
|
+
const makeApp = (root, ui, mcp) => new App(mcp ? { ...cfg, mcp: { extra: [mcp] } } : cfg, ui, root)
|
|
1442
|
+
process.exit(await runAcp({ makeApp, version: VERSION, permission: f['permission-mode'] ? PERMISSION_MODES[f['permission-mode']] : (cfg.permissions ?? 'ask') }))
|
|
1443
|
+
}
|
|
1444
|
+
const headless = f.print || f['output-format'] || f['input-format'] === 'stream-json'
|
|
1445
|
+
if (f['max-retries'] !== undefined || headless) cfg.maxRetries = Number(f['max-retries'] ?? 10) // nobody is there to press Esc
|
|
1446
|
+
if (f['max-turns']) cfg.maxTurns = Number(f['max-turns'])
|
|
1447
|
+
// A UI and an App on it (one per tab; io: the tab's input and output).
|
|
1448
|
+
const newApp = (cfg, root, io) => {
|
|
1449
|
+
const ui = headless ? new HeadlessUI({ verbose: f.verbose }) : new UI(io)
|
|
1450
|
+
// Everything is allowed unless the user chose otherwise (saved), or --ask for this run.
|
|
1451
|
+
ui.permission = f['dangerously-skip-permissions'] ? 'all' : f['permission-mode'] ? PERMISSION_MODES[f['permission-mode']] : f.ask ? 'ask' : f.yes ? 'all' : (cfg.permissions ?? 'all')
|
|
1452
|
+
if (!headless) {
|
|
1453
|
+
ui.onPermission = (mode) => {
|
|
1454
|
+
if (mode === 'plan') return // for this session only: HeartCode doesn't start in plan mode
|
|
1455
|
+
cfg.permissions = mode
|
|
1456
|
+
saveConfig(cfg)
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
ui.showThinking = !f['hide-thinking']
|
|
1460
|
+
const app = new App(cfg, ui, root)
|
|
1461
|
+
usePromptsFrom(app.root) // its .heartcode/prompts override the built-in sections
|
|
1462
|
+
ui.suggest = (line, cursor) => app.suggest(line, cursor)
|
|
1463
|
+
ui.status = () => app.status()
|
|
1464
|
+
ui.requestSince = () => app.s?.client.inFlight // the spinner shows how long the model has been answering
|
|
1465
|
+
return app
|
|
1466
|
+
}
|
|
1467
|
+
let root = path.resolve(f.cwd ?? process.cwd())
|
|
1468
|
+
const wt = f.worktree !== undefined ? createWorktree(root, f.worktree) : null
|
|
1469
|
+
if (wt) {
|
|
1470
|
+
root = wt.cwd
|
|
1471
|
+
log('worktree', `working in ${wt.dir} on ${wt.branch}`)
|
|
1472
|
+
process.on('exit', () => wt.done || activeUI?.noUser || console.error(c.dim(`worktree kept: ${wt.dir} (branch ${wt.branch})`)))
|
|
1473
|
+
}
|
|
1474
|
+
// The full screen: in tabs (the first one is this folder; /tab and Ctrl+T open more).
|
|
1475
|
+
if (!headless && !sub && !positionals.length && process.stdin.isTTY && process.stdout.isTTY) {
|
|
1476
|
+
if (f['resume-id'] && !loadChat(f['resume-id'])) throw new Error(`no session ${f['resume-id']}`)
|
|
1477
|
+
const saved = readJson(TABS_FILE, {})[root] // before this run's first tab replaces it
|
|
1478
|
+
const asked = f.continue || f.resume || f['resume-id'] || f['session-id'] // a session was chosen already
|
|
1479
|
+
const tabs = new Tabs((tab, o) => {
|
|
1480
|
+
const app = newApp(o.first ? cfg : { ...cfg }, o.root, tab)
|
|
1481
|
+
Object.assign(tab, { app, ui: app.ui })
|
|
1482
|
+
Object.assign(app.ui, tab.hooks)
|
|
1483
|
+
Object.assign(app, { tabs, tab, worktree: o.wt ?? null })
|
|
1484
|
+
if (o.first) {
|
|
1485
|
+
if (f['no-memory']) app.memory = null
|
|
1486
|
+
if (f['session-id']) app.setChat(loadChat(f['session-id']) ?? { ...newChat(app.root), id: f['session-id'] })
|
|
1487
|
+
}
|
|
1488
|
+
return (async () => {
|
|
1489
|
+
await repl(app, o.first ? { ...f, onStart: !asked && ((a) => continueTabs(a, saved)) } : o.chat ? { 'resume-id': o.chat.id, model: o.model } : {})
|
|
1490
|
+
if (tabs.quitting) {
|
|
1491
|
+
// quitting: every tab's worktree is kept or removed, then out (other tabs' work stops)
|
|
1492
|
+
for (const t of tabs.list) await leaveWorktree(t.app)
|
|
1493
|
+
process.exit(process.exitCode ?? 0)
|
|
1494
|
+
}
|
|
1495
|
+
if (tabs.list.includes(tab)) await leaveWorktree(app) // the last tab: the full screen is gone
|
|
1496
|
+
else leaveTabWorktree(app)
|
|
1497
|
+
})()
|
|
1498
|
+
})
|
|
1499
|
+
tabs.root = root
|
|
1500
|
+
tabs.onChange = () => saveTabs(tabs)
|
|
1501
|
+
watchSettings(() => tabs.list.map((t) => t.app), cfg)
|
|
1502
|
+
tabs.onShow = (t) => (activeUI = t.ui)
|
|
1503
|
+
tabs.add({ root, wt, first: true })
|
|
1504
|
+
await tabs.done
|
|
1505
|
+
process.exit(process.exitCode ?? 0) // the last tab closed: quit, even if a timer or an MCP server is still around
|
|
1506
|
+
}
|
|
1507
|
+
const app = newApp(cfg, root)
|
|
1508
|
+
const { ui } = app
|
|
1509
|
+
activeUI = ui
|
|
1510
|
+
app.worktree = wt
|
|
1511
|
+
if (sub === 'providers') return app.listProviders(), ui.close()
|
|
1512
|
+
if (sub === 'models') return await app.listModels(positionals[1] ?? '', { all: f.all, json: f.json }), ui.close()
|
|
1513
|
+
if (sub === 'probe') {
|
|
1514
|
+
cfg.force = true
|
|
1515
|
+
const s = await app.session()
|
|
1516
|
+
if (s) ui.print(describe(s.profile, s.strat))
|
|
1517
|
+
ui.close()
|
|
1518
|
+
process.exitCode = s ? 0 : 1
|
|
1519
|
+
return
|
|
1520
|
+
}
|
|
1521
|
+
if (f['no-memory']) app.memory = null
|
|
1522
|
+
const chat = f['resume-id'] ? loadChat(f['resume-id']) : f.continue && headless ? listChats(app.root)[0] : f['session-id'] ? (loadChat(f['session-id']) ?? { ...newChat(app.root), id: f['session-id'] }) : null
|
|
1523
|
+
if (f['resume-id'] && !chat) throw new Error(`no session ${f['resume-id']}`)
|
|
1524
|
+
if (chat && (headless || !f['resume-id'])) app.setChat(chat) // interactive: resumed below, with its transcript
|
|
1525
|
+
if (headless) {
|
|
1526
|
+
app.allowTools = [...(f.allowedTools ?? []), ...(f['allowed-tools'] ?? [])]
|
|
1527
|
+
app.denyTools = [...(f.disallowedTools ?? []), ...(f['disallowed-tools'] ?? [])]
|
|
1528
|
+
const code = await runHeadless(app, { prompt: positionals.join(' '), input: f['input-format'], output: f['output-format'], appendSystem: f['append-system-prompt'] })
|
|
1529
|
+
if (wt && worktreeState(wt).empty) removeWorktree(wt) // else kept, for the caller to merge (its path is in the log)
|
|
1530
|
+
process.exit(code) // MCP servers are stopped by the exit hooks
|
|
1531
|
+
}
|
|
1532
|
+
if (positionals.length) {
|
|
1533
|
+
const ok = await runTask(app, positionals.join(' '))
|
|
1534
|
+
ui.close()
|
|
1535
|
+
await leaveWorktree(app)
|
|
1536
|
+
process.exit(ok ? 0 : 1) // MCP servers are stopped by the exit hooks
|
|
1537
|
+
}
|
|
1538
|
+
watchSettings(() => [app], cfg)
|
|
1539
|
+
await repl(app, f)
|
|
1540
|
+
await leaveWorktree(app)
|
|
1541
|
+
process.exit(process.exitCode ?? 0) // /exit or Ctrl+D: quit, even if a timer or an MCP server is still around
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
let activeUI = null
|
|
1545
|
+
process.on('uncaughtException', (e) => {
|
|
1546
|
+
log('crash', e.stack)
|
|
1547
|
+
activeUI?.close()
|
|
1548
|
+
console.error(`${c.red('crash:')} ${e.stack}`)
|
|
1549
|
+
process.exit(1)
|
|
1550
|
+
})
|
|
1551
|
+
// Killed from outside (kill, closing the tab): give the terminal back as it was first.
|
|
1552
|
+
for (const sig of ['SIGTERM', 'SIGHUP']) {
|
|
1553
|
+
process.on(sig, () => {
|
|
1554
|
+
log('exit', `got ${sig}`)
|
|
1555
|
+
activeUI?.close()
|
|
1556
|
+
process.exit(128 + (sig === 'SIGTERM' ? 15 : 1))
|
|
1557
|
+
})
|
|
1558
|
+
}
|
|
1559
|
+
process.on('unhandledRejection', (e) => log('crash', 'unhandled rejection:', e?.stack ?? e))
|
|
1560
|
+
// After the full screen is gone (the UI registers its exit hook later, so leave it here first).
|
|
1561
|
+
process.on('exit', (code) => {
|
|
1562
|
+
log('exit', `code ${code}`)
|
|
1563
|
+
const logs = [LOG_FILE ?? (LOG_DIR && fs.existsSync(LOG_DIR) ? LOG_DIR : null), requestLog.dir].filter(Boolean)
|
|
1564
|
+
if (!logs.length) return
|
|
1565
|
+
activeUI?.leaveFullscreen()
|
|
1566
|
+
process.stderr.write(c.dim(`${logs.map((l) => `${l === requestLog.dir ? 'requests' : 'log'}: ${l}`).join('\n')}\n`))
|
|
1567
|
+
})
|
|
1568
|
+
main().catch((e) => {
|
|
1569
|
+
log('error', e.stack)
|
|
1570
|
+
activeUI?.close() // leave full screen first, so the error stays visible
|
|
1571
|
+
console.error(`${c.red('error:')} ${e.message}`)
|
|
1572
|
+
process.exit(1)
|
|
1573
|
+
})
|