heartcode 0.1.15 → 0.1.25
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/CHANGELOG.md +198 -0
- package/SKILL.md +5 -3
- package/bin/heartcode.js +235 -84
- package/package.json +1 -1
- package/prompts/agent.md +147 -17
- package/prompts/guided.md +6 -3
- package/src/acp.js +2 -2
- package/src/agent.js +418 -34
- package/src/client.js +59 -3
- package/src/config.js +11 -6
- package/src/headless.js +1 -1
- package/src/lsp.js +168 -49
- package/src/mcp.js +20 -2
- package/src/sessions.js +2 -3
- package/src/settings.html +262 -131
- package/src/settings.js +11 -2
- package/src/skills.js +20 -1
- package/src/tabs.js +22 -7
- package/src/tools.js +68 -6
- package/src/ui.js +525 -95
- package/src/voice.js +70 -6
- package/src/web.js +211 -13
package/bin/heartcode.js
CHANGED
|
@@ -7,7 +7,7 @@ import { probe, loadProfile, strategy, describe, profilePath, profileFrom } from
|
|
|
7
7
|
import { learnFromUsage, LIMIT_MS } from '../src/context.js'
|
|
8
8
|
import { Session, transcript } from '../src/agent.js'
|
|
9
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'
|
|
10
|
+
import { UI, c, PERMISSIONS, duration, copyText, rank, setTheme, themeCodes, renderDiff, diffLines, DEFAULT_KEYS, loadKeys } from '../src/ui.js'
|
|
11
11
|
import { listFiles, runCommand, clip } from '../src/tools.js'
|
|
12
12
|
import { log, LOG_FILE, LOG_DIR, requestLog, useRequestLog, useLogs, pruneLogs, logDays } from '../src/log.js'
|
|
13
13
|
import { prompt, usePromptsFrom, reloadPrompts } from '../src/prompts.js'
|
|
@@ -97,11 +97,11 @@ ${c.bold('in a session')} attach an image with @path/to/shot.png · /help for c
|
|
|
97
97
|
// [name, description, takes an argument], by section. /help and the "/" menu are built from it.
|
|
98
98
|
const SECTIONS = [
|
|
99
99
|
['providers and models', [
|
|
100
|
-
['/select_model', '
|
|
100
|
+
['/select_model', 'pick the model(s) for this session: one pins it, several rotate among them, auto = your whole list', true],
|
|
101
101
|
]],
|
|
102
102
|
['settings', [
|
|
103
103
|
['/toggle_thinking', "show or collapse the model's thinking"],
|
|
104
|
-
['/voice', 'say your message
|
|
104
|
+
['/voice', 'say your message: Space to talk, Enter to finish; it shows in the input box as you speak'],
|
|
105
105
|
]],
|
|
106
106
|
['session', [
|
|
107
107
|
['/resume', 'continue an earlier session in this folder'],
|
|
@@ -126,6 +126,7 @@ const SECTIONS = [
|
|
|
126
126
|
['/settings', 'change providers, models, settings, MCP servers, hooks, keys and skills in your browser'],
|
|
127
127
|
['/help', 'this list'],
|
|
128
128
|
['/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],
|
|
129
|
+
['/close_tab', 'close this tab (Ctrl+Q); asks first'],
|
|
129
130
|
['/exit', 'quit HeartCode (Ctrl+Q closes just this tab)'],
|
|
130
131
|
]],
|
|
131
132
|
]
|
|
@@ -139,6 +140,7 @@ const HOWTO = `${c.yellow('No model set up yet.')} Add a provider and choose its
|
|
|
139
140
|
|
|
140
141
|
${c.dim('Ollama, LM Studio or llama.cpp running on this machine is found on its own.')}`
|
|
141
142
|
const NO_MODELS = `${c.yellow('No model chosen yet.')} Choose one on ${c.cyan('/settings')}: a provider's models, click one.`
|
|
143
|
+
const ALL_OFF = `${c.yellow('The providers of all your models are switched off.')} Switch one on on ${c.cyan('/settings')}, or add a model of one that's on.`
|
|
142
144
|
|
|
143
145
|
// Choices for commands that take one (also offered in the "/" menu).
|
|
144
146
|
const ARGS = {
|
|
@@ -166,6 +168,23 @@ class App {
|
|
|
166
168
|
this.director = new Director(this) // the manage tool: skills, MCP servers and settings by asking
|
|
167
169
|
this.later = [] // setting changes that wait until the current message is done
|
|
168
170
|
this.failures = {} // "provider model" → failed requests this session (engine: onFail)
|
|
171
|
+
this.loadProjectModels() // the model(s) you last chose for this folder (.heartcode/models.json)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Per folder: the model set you picked with /select_model, remembered in .heartcode/models.json and
|
|
175
|
+
// restored when you open this folder again. Absent/empty means auto (your whole list). A -m model
|
|
176
|
+
// from the command line (cfg.session already set) wins and isn't overwritten.
|
|
177
|
+
loadProjectModels() {
|
|
178
|
+
if (this.cfg.session) return
|
|
179
|
+
const saved = readJson(path.join(this.root, '.heartcode', 'models.json'), null)
|
|
180
|
+
const valid = (saved?.session ?? []).filter((t) => t?.provider && t?.model && this.cfg.providers[t.provider])
|
|
181
|
+
if (valid.length) this.cfg.session = valid
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
saveProjectModels() {
|
|
185
|
+
const file = path.join(this.root, '.heartcode', 'models.json')
|
|
186
|
+
if (this.cfg.session?.length) writeJson(file, { session: this.cfg.session.map((t) => ({ provider: t.provider, model: t.model })) })
|
|
187
|
+
else fs.rmSync(file, { force: true }) // auto: forget the folder's choice
|
|
169
188
|
}
|
|
170
189
|
|
|
171
190
|
newMcp() {
|
|
@@ -206,7 +225,7 @@ class App {
|
|
|
206
225
|
`effort: ${cfg.effort ?? 'default'}`,
|
|
207
226
|
`auto_compact: ${cfg.autoCompact === false ? 'off' : 'on'}`,
|
|
208
227
|
`director: ${cfg.director === false ? 'off' : 'on'}`,
|
|
209
|
-
`model: ${
|
|
228
|
+
`model: ${this.inUse() ? `${this.inUse().provider} ${this.inUse().model}` : '(none)'}`,
|
|
210
229
|
`providers: ${Object.keys(cfg.providers).join(', ') || '(none)'}`,
|
|
211
230
|
].join('\n')
|
|
212
231
|
}
|
|
@@ -287,17 +306,38 @@ class App {
|
|
|
287
306
|
if (this.s?.client === client) this.s.profile = profile // /model_info, the settings page
|
|
288
307
|
}
|
|
289
308
|
apply()
|
|
309
|
+
// the profile file as this run last read or wrote it: changed since (the settings page, another
|
|
310
|
+
// HeartCode, a helper on the same model), it's read again before the next request
|
|
311
|
+
let stamp = stampOf(profilePath(client))
|
|
290
312
|
client.onUse = (t) => {
|
|
291
313
|
profile = loadProfile(client) || profileFrom(client, limitsOf(t.provider, client.baseUrl, t.model))
|
|
314
|
+
stamp = stampOf(profilePath(client))
|
|
292
315
|
apply()
|
|
293
316
|
log('rotate', `${t.provider} · ${t.model}: ${strat.actions}${strat.vision ? ', images' : ''}, ~${strat.context} tokens`)
|
|
294
317
|
}
|
|
295
|
-
//
|
|
296
|
-
//
|
|
318
|
+
// The settings as they are now, whatever did or didn't tell this run they changed: before every
|
|
319
|
+
// request (this model's, a helper's, guided mode's, a summary's) and before a tool's result is cut
|
|
320
|
+
// to the window (a command can run for minutes). Nothing goes on with an old copy.
|
|
321
|
+
client.refresh = () => {
|
|
322
|
+
syncSettings()
|
|
323
|
+
if (stampOf(profilePath(client)) !== stamp) client.onUse({ provider: client.provider, model: client.model })
|
|
324
|
+
}
|
|
325
|
+
// What this run learned about the model, onto its file as it is now: the rest (what you set up for
|
|
326
|
+
// it on the settings page, a measured window, models.dev's word) stays the file's. The provider's
|
|
327
|
+
// refusals that another connection to it (a helper) learned meanwhile are kept too.
|
|
297
328
|
const save = () => {
|
|
298
|
-
const
|
|
299
|
-
|
|
300
|
-
|
|
329
|
+
const file = profilePath(client)
|
|
330
|
+
const disk = readJson(file, null)
|
|
331
|
+
if (disk?.model === profile.model) {
|
|
332
|
+
const out = { ...disk, quirks: { ...disk.quirks, ...profile.quirks } }
|
|
333
|
+
for (const k of LEARNED) k in profile ? (out[k] = profile[k]) : delete out[k]
|
|
334
|
+
if (disk.source === 'assumed' && profile.source !== 'assumed') out.source = profile.source // models.dev knows it now
|
|
335
|
+
profile = out
|
|
336
|
+
}
|
|
337
|
+
writeJson(file, profile)
|
|
338
|
+
stamp = stampOf(file)
|
|
339
|
+
settingsSeen('profile') // its own write: not a change to take in
|
|
340
|
+
apply()
|
|
301
341
|
}
|
|
302
342
|
// What the window is, learned while working (context.js); kept in the profile for next time.
|
|
303
343
|
const learned = () => {
|
|
@@ -386,7 +426,8 @@ class App {
|
|
|
386
426
|
'Go back to before which message?',
|
|
387
427
|
turns.map(({ t, i }) => {
|
|
388
428
|
const n = Object.keys(chat.turns.slice(i).reduce((all, x) => Object.assign(all, x.files), {})).length
|
|
389
|
-
|
|
429
|
+
const num = `${i + 1}.`.padStart(String(chat.turns.length).length + 1) // its number in the conversation, newest first
|
|
430
|
+
return { label: `${num} ${t.task.split('\n')[0].slice(0, 70)}`, desc: `${ago(t.at)}${n ? ` · ${n} file${n > 1 ? 's' : ''} changed since` : ''}`, i }
|
|
390
431
|
}),
|
|
391
432
|
)
|
|
392
433
|
if (!pickTurn) return
|
|
@@ -561,33 +602,50 @@ class App {
|
|
|
561
602
|
const sep = c.ghost(' │ ')
|
|
562
603
|
if (!cfg.providers[cfg.provider] || !cfg.model) return c.dim('no model · /settings to choose one')
|
|
563
604
|
const rot = this.s?.client.rotation
|
|
564
|
-
const now =
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
605
|
+
const now = this.inUse() // the model list's one in use; never one of a provider that's off
|
|
606
|
+
if (!now) return c.dim('the providers of all your models are off · /settings to switch one on')
|
|
607
|
+
// provider (providers on) · the model. Auto (rotating your list): "auto (current model) (list
|
|
608
|
+
// size)". A model you picked (pinned, no rotation): just its name.
|
|
609
|
+
const on = Object.values(cfg.providers).filter((p) => !p.off).length
|
|
610
|
+
// one model (pinned): just its name. Several chosen for this session: the one in use + "N chosen".
|
|
611
|
+
// Your whole list (auto): "auto (in use) (N)". ↻ shows the rotation position while rotating.
|
|
612
|
+
const count = this.sessionModels().length
|
|
613
|
+
// the model answering now; a "↻" before it, in yellow, when it's a fallback (rotated to after one
|
|
614
|
+
// failed) — so you can see at a glance it isn't the first/preferred one
|
|
615
|
+
const rotated = rot && rot.at > 0
|
|
616
|
+
const name = rotated ? `${c.yellow('↻ ' + now.model)}` : c.info(now.model)
|
|
617
|
+
const model =
|
|
618
|
+
count === 1
|
|
619
|
+
? name
|
|
620
|
+
: cfg.session
|
|
621
|
+
? `${name} ${c.faint(`(${count} chosen)`)}`
|
|
622
|
+
: `${c.muted('auto')} ${c.faint('(')}${name}${c.faint(')')} ${c.faint(`(${count})`)}`
|
|
623
|
+
// two lines so nothing is crammed: the model and what it can do, then the run's stats and controls
|
|
624
|
+
const first = [`${c.brand('●')} ${c.muted(now.provider)} ${c.faint(`(${on})`)} ${c.ghost('·')} ${model}`]
|
|
625
|
+
const second = []
|
|
626
|
+
const key = `${now.provider}/${now.model}`
|
|
627
|
+
if (this.profileKey !== key) [this.profileKey, this.profile] = [key, this.s?.profile ?? loadProfile(this.client(now.provider, now.model))]
|
|
568
628
|
const profile = this.s?.profile ?? this.profile
|
|
569
629
|
if (profile) {
|
|
570
630
|
const strat = this.s?.strat ?? strategy(profile, cfg)
|
|
571
|
-
|
|
572
|
-
if (strat.thinking)
|
|
573
|
-
if (strat.vision)
|
|
631
|
+
first.push(c.muted(strat.actions === 'tools' ? 'tools' : 'guided'))
|
|
632
|
+
if (strat.thinking) first.push(c.think(`thinking${cfg.effort ? ` · ${cfg.effort}` : ''}`) + (ui.showThinking ? '' : c.faint(' (collapsed)')))
|
|
633
|
+
if (strat.vision) first.push(c.muted('images'))
|
|
574
634
|
const used = this.s?.client.lastTokens ?? 0
|
|
575
635
|
const pct = Math.round((used / strat.context) * 100)
|
|
576
636
|
const paint = pct >= 80 ? c.red : pct >= 50 ? c.yellow : c.green
|
|
577
637
|
const guess = strat.contextSure ? '' : '~' // not said by the server: assumed, and growing as bigger requests work
|
|
578
|
-
|
|
638
|
+
second.push(`${c.faint('ctx')} ${paint(`${kTokens(used)}/${guess}${kTokens(strat.context)}`)} ${c.faint(`${pct}%`)}`)
|
|
579
639
|
const tps = this.s?.client.tps
|
|
580
|
-
if (tps)
|
|
581
|
-
if (this.chat.usage?.priced)
|
|
640
|
+
if (tps) second.push(`${c.muted(String(tps))} ${c.faint('tok/s')}`)
|
|
641
|
+
if (this.chat.usage?.priced) second.push(c.muted(money(this.chat.usage.cost)))
|
|
582
642
|
} else {
|
|
583
|
-
|
|
643
|
+
first.push(c.dim('not probed yet'))
|
|
584
644
|
}
|
|
585
|
-
if (cfg.autoCompact === false)
|
|
586
|
-
if (this.worktree)
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
parts.push(perm) // Shift+Tab changes it (see the header)
|
|
590
|
-
return parts.join(sep)
|
|
645
|
+
if (cfg.autoCompact === false) second.push(c.faint('auto-compact off'))
|
|
646
|
+
if (this.worktree) second.push(c.info(`⎇ ${this.worktree.branch}`))
|
|
647
|
+
second.push({ all: c.green('⏵⏵ all allowed'), commands: c.yellow('ask before commands'), ask: c.yellow('ask before changes'), plan: c.info('⏸ plan mode') }[ui.permission]) // Shift+Tab changes it
|
|
648
|
+
return [first.join(sep), second.join(sep)].filter((l) => l.replace(/\x1b\[[0-9;]*m/g, '').trim()).join('\n')
|
|
591
649
|
}
|
|
592
650
|
|
|
593
651
|
client(name, model) {
|
|
@@ -627,7 +685,13 @@ class App {
|
|
|
627
685
|
ui.print(NO_MODELS)
|
|
628
686
|
return null
|
|
629
687
|
}
|
|
630
|
-
|
|
688
|
+
// this session's models (a set you chose, else your list), first one whose provider is on
|
|
689
|
+
const start = this.sessionModels()[0]
|
|
690
|
+
if (!start) {
|
|
691
|
+
ui.print(ALL_OFF)
|
|
692
|
+
return null
|
|
693
|
+
}
|
|
694
|
+
const { client, profile, strat } = await this.engine(start.provider, start.model, ui, this.signal)
|
|
631
695
|
this.s = new Session({ client, profile, strat, root: this.root, ui, chat: this.chat }) // details: the status bar
|
|
632
696
|
this.s.autoCompact = cfg.autoCompact !== false
|
|
633
697
|
this.s.onTurn = () => this.save()
|
|
@@ -644,12 +708,12 @@ class App {
|
|
|
644
708
|
return this.s
|
|
645
709
|
}
|
|
646
710
|
|
|
647
|
-
// The
|
|
648
|
-
//
|
|
711
|
+
// The session's models as a rotation on the client: this session's set (sessionModels), then
|
|
712
|
+
// --fallback-model's. One model: no rotation.
|
|
649
713
|
useRotation() {
|
|
650
714
|
const { cfg } = this
|
|
651
715
|
const seen = new Set()
|
|
652
|
-
const list = [
|
|
716
|
+
const list = [...this.sessionModels(), ...(cfg.fallbacks ?? [])]
|
|
653
717
|
.filter((t) => cfg.providers[t.provider] && t.model && !seen.has(`${t.provider}/${t.model}`) && seen.add(`${t.provider}/${t.model}`))
|
|
654
718
|
.map((t) => ({ ...t, baseUrl: cfg.providers[t.provider].baseUrl, apiKey: cfg.providers[t.provider].apiKey }))
|
|
655
719
|
this.s?.client.setRotation(list, cfg.rotation ?? {})
|
|
@@ -666,8 +730,10 @@ class App {
|
|
|
666
730
|
// Your other models (not the one in use) and what each can do, from its saved profile or
|
|
667
731
|
// models.dev: nothing is sent to them to find out.
|
|
668
732
|
team() {
|
|
669
|
-
|
|
670
|
-
|
|
733
|
+
// helpers use this session's models (the set you chose, else your list), minus the one in use —
|
|
734
|
+
// so a pinned single model gives no team, and a chosen set keeps helpers within it
|
|
735
|
+
const now = this.s?.client.rotation?.list[this.s.client.rotation.at] ?? this.sessionModels()[0]
|
|
736
|
+
return this.sessionModels()
|
|
671
737
|
.filter((t) => !(t.provider === now?.provider && t.model === now?.model))
|
|
672
738
|
.map((t) => ({ ...t, ...this.abilities(t) }))
|
|
673
739
|
}
|
|
@@ -693,22 +759,62 @@ class App {
|
|
|
693
759
|
|
|
694
760
|
// Your models, in order: this one (#1), then the fallbacks (chosen on /settings, where every
|
|
695
761
|
// provider's whole list is).
|
|
696
|
-
|
|
762
|
+
// The model answering now: the model list's current one, the session's, or (none yet) the one it
|
|
763
|
+
// will start with: the first of yours whose provider is on (a model given for this run: that one).
|
|
764
|
+
// What the screen shows as "the model": never one of a provider that's switched off.
|
|
765
|
+
inUse() {
|
|
766
|
+
const { cfg } = this
|
|
767
|
+
const rot = this.s?.client.rotation
|
|
768
|
+
if (rot) return rot.list[rot.at]
|
|
769
|
+
if (this.s) return { provider: this.s.client.provider, model: this.s.client.model }
|
|
770
|
+
return this.sessionModels()[0] ?? null
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// your list has models, but the providers of all of them are switched off
|
|
774
|
+
allOff() {
|
|
775
|
+
return !this.cfg.session && this.myModels(true).length > 0 && !this.myModels().length
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// all: also the ones of a provider switched off (on the settings page), which are skipped otherwise
|
|
779
|
+
myModels(all = false) {
|
|
697
780
|
const { cfg } = this
|
|
698
781
|
const seen = new Set()
|
|
699
|
-
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}`))
|
|
782
|
+
return [{ provider: cfg.provider, model: cfg.model }, ...(cfg.rotation?.then ?? [])].filter((t) => cfg.providers[t.provider] && (all || !cfg.providers[t.provider].off) && t.model && !seen.has(`${t.provider}/${t.model}`) && seen.add(`${t.provider}/${t.model}`))
|
|
700
783
|
}
|
|
701
784
|
|
|
702
|
-
//
|
|
703
|
-
|
|
785
|
+
// The models THIS session uses: the set you chose with /select_model (cfg.session, session-local),
|
|
786
|
+
// else your whole list. One model: no rotation (pinned). Several: it rotates among just those.
|
|
787
|
+
sessionModels(all = false) {
|
|
704
788
|
const { cfg } = this
|
|
705
|
-
|
|
706
|
-
const
|
|
707
|
-
cfg.
|
|
708
|
-
await this.use(t.provider, t.model)
|
|
789
|
+
if (!cfg.session) return this.myModels(all)
|
|
790
|
+
const seen = new Set()
|
|
791
|
+
return cfg.session.filter((t) => cfg.providers[t.provider] && (all || !cfg.providers[t.provider].off) && t.model && !seen.has(`${t.provider}/${t.model}`) && seen.add(`${t.provider}/${t.model}`))
|
|
709
792
|
}
|
|
710
793
|
|
|
711
|
-
//
|
|
794
|
+
// Use a chosen set of models for this session: one pins it (no rotation), several rotate among
|
|
795
|
+
// just those. Remembered for this folder (.heartcode/models.json); the saved ranking is untouched.
|
|
796
|
+
async setSession(list) {
|
|
797
|
+
const { cfg } = this
|
|
798
|
+
cfg.session = list // the global #1 (cfg.provider/model) stays, so your whole list and the picker are unchanged
|
|
799
|
+
this.saveProjectModels()
|
|
800
|
+
this.s = null
|
|
801
|
+
this.ui.print(list.length === 1 ? `${c.green('using')} ${c.cyan(list[0].model)} ${c.dim('· pinned for this folder')}` : `${c.green(`using ${list.length} models for this folder`)}: ${c.cyan(list.map((t) => t.model).join(', '))}`)
|
|
802
|
+
await this.session()
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// Back to auto: your whole list again (#1, then the next when one fails), following the saved
|
|
806
|
+
// ranking. The folder's remembered choice is cleared.
|
|
807
|
+
async auto() {
|
|
808
|
+
const { cfg } = this
|
|
809
|
+
delete cfg.session
|
|
810
|
+
this.saveProjectModels()
|
|
811
|
+
this.s = null
|
|
812
|
+
this.ui.print(`${c.green('using')} ${c.dim('auto')} ${c.cyan(`(${this.myModels().length} models)`)}`)
|
|
813
|
+
await this.session()
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// The picker: tick the models for this session — one pins it, several rotate among them, all of
|
|
817
|
+
// them (or none) is the same as auto (your whole list). "auto" is the first entry.
|
|
712
818
|
async chooseModel(filter = '') {
|
|
713
819
|
const { ui, cfg } = this
|
|
714
820
|
const mine = this.myModels()
|
|
@@ -716,20 +822,40 @@ class App {
|
|
|
716
822
|
if (!Object.keys(cfg.providers).length && (await this.detectLocal())) return this.session()
|
|
717
823
|
return ui.print(NO_MODELS)
|
|
718
824
|
}
|
|
719
|
-
const
|
|
720
|
-
const
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
825
|
+
const key = (t) => `${t.provider}/${t.model}`
|
|
826
|
+
const chosen = new Set((cfg.session ?? mine).map(key)) // pre-ticked: this session's set, or all (auto)
|
|
827
|
+
const items = mine.map((t) => ({ label: t.model, t, checked: chosen.has(key(t)), desc: this.modelDesc(t, mine.indexOf(t)) }))
|
|
828
|
+
const picked = await ui.pick('Models for this session — tick several to rotate, one to pin (all = auto)', items, { filter, multi: true })
|
|
829
|
+
if (!picked) return
|
|
830
|
+
const list = picked.map((it) => it.t)
|
|
831
|
+
return list.length >= mine.length ? this.auto() : this.setSession(list)
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// "/select_model a, b, c": use those models this session (one pins, several rotate). One model:
|
|
835
|
+
// "model", "provider model" or "provider/model". "/select_model auto": your whole list. No
|
|
836
|
+
// argument: the picker.
|
|
726
837
|
async pickModel(args) {
|
|
727
838
|
if (!args.length) return this.chooseModel()
|
|
728
|
-
|
|
729
|
-
const
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
839
|
+
if (args.every((a) => isAuto(a))) return this.cfg.session ? this.auto() : this.ui.print(c.dim('already on auto'))
|
|
840
|
+
const raw = args.join(' ')
|
|
841
|
+
const specs = raw.includes(',') ? raw.split(',').map((x) => x.trim()).filter(Boolean) : [raw]
|
|
842
|
+
const resolve = (spec) => {
|
|
843
|
+
const w = spec.trim().split(/\s+/)
|
|
844
|
+
const tries = [[null, spec.trim()], w.length > 1 && [w[0], w.slice(1).join(' ')], spec.includes('/') && [spec.slice(0, spec.indexOf('/')), spec.slice(spec.indexOf('/') + 1)]].filter(Boolean)
|
|
845
|
+
for (const [name, model] of tries) {
|
|
846
|
+
const hit = this.myModels().find((t) => t.model === model && (!name || t.provider === name))
|
|
847
|
+
if (hit) return hit
|
|
848
|
+
}
|
|
849
|
+
return null
|
|
850
|
+
}
|
|
851
|
+
const found = []
|
|
852
|
+
const missing = []
|
|
853
|
+
for (const spec of specs) (resolve(spec) ? found : missing).push(resolve(spec) ?? spec)
|
|
854
|
+
if (missing.length || !found.length) {
|
|
855
|
+
if (missing.length) this.ui.print(c.dim(`not in your models: ${missing.join(', ')} · add on /settings`))
|
|
856
|
+
return this.chooseModel(specs.length === 1 ? specs[0] : '')
|
|
857
|
+
}
|
|
858
|
+
return this.setSession(found)
|
|
733
859
|
}
|
|
734
860
|
|
|
735
861
|
listProviders() {
|
|
@@ -738,7 +864,7 @@ class App {
|
|
|
738
864
|
if (!names.length) return ui.print(`No providers yet: add one with ${c.cyan('heart settings')}.`)
|
|
739
865
|
for (const n of names) {
|
|
740
866
|
const p = cfg.providers[n]
|
|
741
|
-
const notes = [n ===
|
|
867
|
+
const notes = [n === this.inUse()?.provider && c.magenta('● current'), p.off && c.yellow('off'), n === cfg.adhoc && c.dim('this run only')].filter(Boolean).join(' ')
|
|
742
868
|
ui.print(`${c.bold(n.padEnd(14))} ${p.baseUrl} ${c.dim(mask(p.apiKey))} ${notes}`)
|
|
743
869
|
}
|
|
744
870
|
}
|
|
@@ -748,13 +874,13 @@ class App {
|
|
|
748
874
|
// provider's models (fetched from each; a provider whose list is off: the ones added by name).
|
|
749
875
|
// json: one JSON object on stdout, for other programs:
|
|
750
876
|
// { "models": [{ "id": "auto", "fallbacks": true }, { "id": "groq/openai/gpt-oss-120b", "provider", "model", "place": 1 }, …],
|
|
751
|
-
// "providers": [{ "name", "baseUrl", "list": true, "models": ["provider/model", …], "error"? }] } (--all)
|
|
877
|
+
// "providers": [{ "name", "baseUrl", "list": true, "off"?: true, "models": ["provider/model", …], "error"? }] } (--all)
|
|
752
878
|
async listModels(filter = '', { all = false, json = false } = {}) {
|
|
753
879
|
const { ui, cfg } = this
|
|
754
880
|
const match = (id) => id.toLowerCase().includes(filter.toLowerCase())
|
|
755
881
|
const mine = this.myModels().map((t, i) => ({ id: `${t.provider}/${t.model}`, provider: t.provider, model: t.model, place: i + 1 }))
|
|
756
882
|
const providers = !all ? [] : await Promise.all(Object.entries(cfg.providers).map(async ([name, p]) => {
|
|
757
|
-
const out = { name, baseUrl: p.baseUrl, list: p.list !== false, models: [] }
|
|
883
|
+
const out = { name, baseUrl: p.baseUrl, list: p.list !== false, ...(p.off && { off: true }), models: [] }
|
|
758
884
|
if (out.list) {
|
|
759
885
|
try {
|
|
760
886
|
out.models = await new Client({ baseUrl: p.baseUrl, apiKey: p.apiKey }).listModels()
|
|
@@ -843,7 +969,8 @@ async function continueTabs(app, saved) {
|
|
|
843
969
|
// A continued tab's own model, as it was (unless its provider is gone: then the current one).
|
|
844
970
|
function useModel(app, { provider, model }) {
|
|
845
971
|
if (!provider || !model || (provider === app.cfg.provider && model === app.cfg.model)) return
|
|
846
|
-
if (!app.cfg.providers[provider]) return app.ui.warn(`this tab used ${provider} · ${model}, but there's no provider "${provider}" now: using
|
|
972
|
+
if (!app.cfg.providers[provider]) return app.ui.warn(`this tab used ${provider} · ${model}, but there's no provider "${provider}" now: using your list`)
|
|
973
|
+
if (app.cfg.providers[provider].off) return app.ui.warn(`this tab used ${provider} · ${model}, but ${provider} is switched off: using your list`)
|
|
847
974
|
Object.assign(app.cfg, { provider, model })
|
|
848
975
|
app.s = null
|
|
849
976
|
}
|
|
@@ -872,13 +999,15 @@ function applySettings(apps, cfg, what) {
|
|
|
872
999
|
const busy = Boolean(ui.busySince || app.working)
|
|
873
1000
|
const now = (fn) => (busy ? app.later.push(fn) : fn())
|
|
874
1001
|
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]
|
|
875
|
-
|
|
1002
|
+
// a model pinned in this CLI (/select_model) is a session choice: a model/order change on the
|
|
1003
|
+
// settings page leaves it alone (only /select_model auto here returns it to the list)
|
|
1004
|
+
if (what === 'model' && !app.cfg.session) {
|
|
876
1005
|
app.lists = {}
|
|
877
|
-
delete app.cfg.fixed // a model chosen on the page: your list again
|
|
878
1006
|
if (app.s && !(cfg.providers[cfg.provider] && cfg.model)) now(() => ((app.s = null), ui.print(c.dim('settings: no model chosen'))))
|
|
1007
|
+
else if (app.s && !app.myModels().length) now(() => ((app.s = null), ui.print(c.dim('settings: the providers of all your models are switched off'))))
|
|
879
1008
|
}
|
|
880
1009
|
// your model list: into the running session now, its next request goes to the new #1
|
|
881
|
-
if ((what === 'model' || what === 'setting:rotation') && app.s && cfg.providers[cfg.provider] && cfg.model) {
|
|
1010
|
+
if ((what === 'model' || what === 'setting:rotation') && !app.cfg.session && app.s && cfg.providers[cfg.provider] && cfg.model) {
|
|
882
1011
|
const was = `${app.s.client.provider} · ${app.s.client.model}`
|
|
883
1012
|
app.useRotation()
|
|
884
1013
|
const is = `${app.s.client.provider} · ${app.s.client.model}`
|
|
@@ -907,6 +1036,12 @@ function applySettings(apps, cfg, what) {
|
|
|
907
1036
|
// taken in as the page's own are (applySettings). config.json: the keys others changed (takeIn; a
|
|
908
1037
|
// model given for this run with -m stays unless the list's #1 changes); the other files by their
|
|
909
1038
|
// size and time, and only when this run didn't just apply them itself.
|
|
1039
|
+
// A model's profile fields a running HeartCode learns itself (context.js, the models.dev lookup); the
|
|
1040
|
+
// rest of the file is the settings page's.
|
|
1041
|
+
const LEARNED = ['inputLimit', 'inputLimitAt', 'cutAt', 'tokenRatio', 'largestAccepted', 'rateCap', 'known']
|
|
1042
|
+
// The settings as they are now, into every HeartCode in this process (watchSettings): run before each
|
|
1043
|
+
// request too, so a change the file watcher hasn't passed on yet (or never will) is still taken in.
|
|
1044
|
+
let syncSettings = () => {}
|
|
910
1045
|
const WATCHED = { 'mcp.json': 'mcp', 'hooks.json': 'hooks', 'keybindings.json': 'keys', 'theme.json': 'theme', models: 'profile', prompts: 'prompts', 'HEARTCODE.md': 'prompts', skills: 'skill' }
|
|
911
1046
|
const seenStamp = {}
|
|
912
1047
|
const stampOf = (p) => {
|
|
@@ -930,6 +1065,7 @@ function watchSettings(apps, cfg) {
|
|
|
930
1065
|
}
|
|
931
1066
|
let timer = null
|
|
932
1067
|
const check = () => {
|
|
1068
|
+
clearTimeout(timer)
|
|
933
1069
|
timer = null
|
|
934
1070
|
configChanged()
|
|
935
1071
|
for (const [name, what] of Object.entries(WATCHED)) {
|
|
@@ -940,6 +1076,7 @@ function watchSettings(apps, cfg) {
|
|
|
940
1076
|
applySettings(apps(), cfg, what)
|
|
941
1077
|
}
|
|
942
1078
|
}
|
|
1079
|
+
syncSettings = check
|
|
943
1080
|
try {
|
|
944
1081
|
const watcher = fs.watch(HOME, { recursive: true }, (_, file) => {
|
|
945
1082
|
const top = String(file ?? '').split(path.sep)[0]
|
|
@@ -991,14 +1128,6 @@ function leaveTabWorktree(app) {
|
|
|
991
1128
|
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)`)}`)
|
|
992
1129
|
}
|
|
993
1130
|
|
|
994
|
-
// The system clipboard (pbcopy, clip, wl-copy, xclip, xsel); else the terminal's (OSC 52, works over SSH too).
|
|
995
|
-
function copyText(text) {
|
|
996
|
-
const tools = process.platform === 'darwin' ? [['pbcopy']] : process.platform === 'win32' ? [['clip']] : [['wl-copy'], ['xclip', '-selection', 'clipboard'], ['xsel', '-b', '-i']]
|
|
997
|
-
for (const [cmd, ...args] of tools) if (spawnSync(cmd, args, { input: text, stdio: ['pipe', 'ignore', 'ignore'] }).status === 0) return 'system'
|
|
998
|
-
process.stdout.write(`\x1b]52;c;${Buffer.from(text).toString('base64')}\x07`)
|
|
999
|
-
return 'osc'
|
|
1000
|
-
}
|
|
1001
|
-
|
|
1002
1131
|
// A model's profile: the saved one; else what models.dev says (nothing is sent to the model to
|
|
1003
1132
|
// find out); a model it doesn't know: plain assumptions, until you set it up on the settings page.
|
|
1004
1133
|
// force (/test_model, heart probe): test it now instead, with real requests.
|
|
@@ -1024,7 +1153,7 @@ async function getProfile(client, ui, force, signal, quiet = false) {
|
|
|
1024
1153
|
const t0 = Date.now()
|
|
1025
1154
|
const tested = await probe(client, (label) => ui.spinner(`testing: ${label}`), signal)
|
|
1026
1155
|
ui.stopSpin()
|
|
1027
|
-
ui.print(c.dim(`done in ${(
|
|
1156
|
+
ui.print(c.dim(`done in ${duration(Date.now() - t0)} · saved to ${profilePath(client)}`))
|
|
1028
1157
|
return tested
|
|
1029
1158
|
}
|
|
1030
1159
|
|
|
@@ -1038,6 +1167,8 @@ async function slash(app, line) {
|
|
|
1038
1167
|
const [cmd, ...args] = line.slice(1).trim().split(/\s+/)
|
|
1039
1168
|
const { ui, cfg } = app
|
|
1040
1169
|
switch (cmd) {
|
|
1170
|
+
case 'close_tab':
|
|
1171
|
+
return void (await app.closeTab())
|
|
1041
1172
|
case 'exit':
|
|
1042
1173
|
case 'quit': {
|
|
1043
1174
|
const n = app.tabs?.list.length ?? 1
|
|
@@ -1123,7 +1254,7 @@ async function slash(app, line) {
|
|
|
1123
1254
|
const format = /\.html?$/i.test(file) ? 'html' : /\.json$/i.test(file) ? 'json' : 'md'
|
|
1124
1255
|
const abs = path.resolve(app.root, file)
|
|
1125
1256
|
fs.mkdirSync(path.dirname(abs), { recursive: true })
|
|
1126
|
-
fs.writeFileSync(abs, exportChat(app.chat, format,
|
|
1257
|
+
fs.writeFileSync(abs, exportChat(app.chat, format, app.inUse() ? `${app.inUse().provider} · ${app.inUse().model}` : ''))
|
|
1127
1258
|
return ui.print(`${c.green('saved')} ${path.relative(app.root, abs) || abs}`)
|
|
1128
1259
|
}
|
|
1129
1260
|
case 'diff': {
|
|
@@ -1164,8 +1295,8 @@ async function slash(app, line) {
|
|
|
1164
1295
|
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' : ''}`)
|
|
1165
1296
|
return ui.print(
|
|
1166
1297
|
u.priced
|
|
1167
|
-
? `${c.bold('cost')} ${money(u.cost)}${price ? c.faint(` (${
|
|
1168
|
-
: c.faint(`cost: not known for ${
|
|
1298
|
+
? `${c.bold('cost')} ${money(u.cost)}${price ? c.faint(` (${app.inUse()?.model}: $${price.input ?? 0} in / $${price.output ?? 0} out per million tokens, from models.dev)`) : ''}`
|
|
1299
|
+
: c.faint(`cost: not known for ${app.inUse()?.model ?? 'this model'} (models.dev doesn't list it; local and subscription models have none)`),
|
|
1169
1300
|
)
|
|
1170
1301
|
}
|
|
1171
1302
|
case 'clear_chat': // a new session; the old one stays in /resume
|
|
@@ -1294,17 +1425,31 @@ async function repl(app, f = {}) {
|
|
|
1294
1425
|
ui.close()
|
|
1295
1426
|
process.exit(0)
|
|
1296
1427
|
}
|
|
1297
|
-
// Ctrl+Q: this tab (working:
|
|
1298
|
-
let
|
|
1299
|
-
|
|
1300
|
-
if (
|
|
1301
|
-
|
|
1302
|
-
|
|
1428
|
+
// Ctrl+Q, /close_tab: this tab, after asking (working: that stops too); the last one quits
|
|
1429
|
+
let closing = false
|
|
1430
|
+
app.closeTab = async (working = false) => {
|
|
1431
|
+
if (closing) return
|
|
1432
|
+
closing = true
|
|
1433
|
+
try {
|
|
1434
|
+
const last = (app.tabs?.list.length ?? 1) < 2
|
|
1435
|
+
const go = await ui.pick(`Close this tab?${working ? ' It is still working: that stops too.' : ''}`, [
|
|
1436
|
+
{ label: 'Yes, close it', desc: last ? 'the last tab: HeartCode quits (it comes back next time)' : undefined, yes: true },
|
|
1437
|
+
{ label: 'No' },
|
|
1438
|
+
])
|
|
1439
|
+
if (!go?.yes) return
|
|
1440
|
+
if (last && app.tabs) app.tabs.quitting = true
|
|
1441
|
+
ctl?.abort()
|
|
1442
|
+
ui.cancelAsk()
|
|
1443
|
+
ui.close()
|
|
1444
|
+
} finally {
|
|
1445
|
+
closing = false
|
|
1303
1446
|
}
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
ui.
|
|
1447
|
+
}
|
|
1448
|
+
ui.onCloseTab = () => {
|
|
1449
|
+
if (closing) return
|
|
1450
|
+
if (!ui.asking) return app.closeTab(true) // working: asked over the work
|
|
1451
|
+
if (!ui.histOn || ui.menu.pick) return ui.notice('Answer this first, then Ctrl+Q', 2000)
|
|
1452
|
+
ui.submit('/close_tab') // at the prompt: asked in its turn, like a typed /close_tab
|
|
1308
1453
|
}
|
|
1309
1454
|
const others = () => (app.tabs?.list.length ?? 1) - 1
|
|
1310
1455
|
ui.onInterrupt = () => {
|
|
@@ -1328,7 +1473,7 @@ async function repl(app, f = {}) {
|
|
|
1328
1473
|
const where = home && app.root.startsWith(home) ? '~' + app.root.slice(home.length) : app.root
|
|
1329
1474
|
ui.header = (cols = 100) => [
|
|
1330
1475
|
`${c.brand('♥')} ${c.bright(c.bold('HeartCode'))} ${c.faint(VERSION)} ${c.muted(where.length > cols - 16 ? '…' + where.slice(-(cols - 17)) : where)}`,
|
|
1331
|
-
[['/', '
|
|
1476
|
+
[['/', 'run a command'], ['@', 'attach a file'], [NEWLINE_KEY, 'add a new line'], ['Esc', 'stop'], ['Shift+Tab', 'change permissions']].map(([k, d]) => `${c.muted(k)} ${c.faint(d)}`).join(c.ghost(' · ')),
|
|
1332
1477
|
'',
|
|
1333
1478
|
]
|
|
1334
1479
|
if (!ui.full) ui.print(ui.header().join('\n'))
|
|
@@ -1376,6 +1521,7 @@ async function repl(app, f = {}) {
|
|
|
1376
1521
|
ui.setPermission(go.mode)
|
|
1377
1522
|
ok = await runTask(app, prompt('agent/plan-approved'), ctl)
|
|
1378
1523
|
}
|
|
1524
|
+
if (ui.busySince) ui.print(c.faint(c.italic(`✻ took ${duration(ui.worked())}`))) // fixed once the reply is in
|
|
1379
1525
|
}
|
|
1380
1526
|
ui.idle()
|
|
1381
1527
|
ctl = null
|
|
@@ -1507,6 +1653,11 @@ async function main() {
|
|
|
1507
1653
|
// Everything is allowed unless the user chose otherwise (saved), or --ask for this run.
|
|
1508
1654
|
ui.permission = f['dangerously-skip-permissions'] ? 'all' : f['permission-mode'] ? PERMISSION_MODES[f['permission-mode']] : f.ask ? 'ask' : f.yes ? 'all' : (cfg.permissions ?? 'all')
|
|
1509
1655
|
if (!headless) {
|
|
1656
|
+
ui.queueMode = cfg.queue ?? 'all'
|
|
1657
|
+
ui.onQueueMode = (mode) => {
|
|
1658
|
+
cfg.queue = mode
|
|
1659
|
+
saveConfig(cfg)
|
|
1660
|
+
}
|
|
1510
1661
|
ui.onPermission = (mode) => {
|
|
1511
1662
|
if (mode === 'plan') return // for this session only: HeartCode doesn't start in plan mode
|
|
1512
1663
|
cfg.permissions = mode
|