heartcode 0.1.14 → 0.1.24
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 +197 -0
- package/SKILL.md +5 -3
- package/bin/heartcode.js +199 -71
- 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 +408 -142
- package/src/settings.js +44 -7
- package/src/skills.js +20 -1
- package/src/tabs.js +22 -7
- package/src/tools.js +68 -6
- package/src/ui.js +525 -94
- 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 = {
|
|
@@ -206,7 +208,7 @@ class App {
|
|
|
206
208
|
`effort: ${cfg.effort ?? 'default'}`,
|
|
207
209
|
`auto_compact: ${cfg.autoCompact === false ? 'off' : 'on'}`,
|
|
208
210
|
`director: ${cfg.director === false ? 'off' : 'on'}`,
|
|
209
|
-
`model: ${
|
|
211
|
+
`model: ${this.inUse() ? `${this.inUse().provider} ${this.inUse().model}` : '(none)'}`,
|
|
210
212
|
`providers: ${Object.keys(cfg.providers).join(', ') || '(none)'}`,
|
|
211
213
|
].join('\n')
|
|
212
214
|
}
|
|
@@ -287,17 +289,38 @@ class App {
|
|
|
287
289
|
if (this.s?.client === client) this.s.profile = profile // /model_info, the settings page
|
|
288
290
|
}
|
|
289
291
|
apply()
|
|
292
|
+
// the profile file as this run last read or wrote it: changed since (the settings page, another
|
|
293
|
+
// HeartCode, a helper on the same model), it's read again before the next request
|
|
294
|
+
let stamp = stampOf(profilePath(client))
|
|
290
295
|
client.onUse = (t) => {
|
|
291
296
|
profile = loadProfile(client) || profileFrom(client, limitsOf(t.provider, client.baseUrl, t.model))
|
|
297
|
+
stamp = stampOf(profilePath(client))
|
|
292
298
|
apply()
|
|
293
299
|
log('rotate', `${t.provider} · ${t.model}: ${strat.actions}${strat.vision ? ', images' : ''}, ~${strat.context} tokens`)
|
|
294
300
|
}
|
|
295
|
-
//
|
|
296
|
-
//
|
|
301
|
+
// The settings as they are now, whatever did or didn't tell this run they changed: before every
|
|
302
|
+
// request (this model's, a helper's, guided mode's, a summary's) and before a tool's result is cut
|
|
303
|
+
// to the window (a command can run for minutes). Nothing goes on with an old copy.
|
|
304
|
+
client.refresh = () => {
|
|
305
|
+
syncSettings()
|
|
306
|
+
if (stampOf(profilePath(client)) !== stamp) client.onUse({ provider: client.provider, model: client.model })
|
|
307
|
+
}
|
|
308
|
+
// What this run learned about the model, onto its file as it is now: the rest (what you set up for
|
|
309
|
+
// it on the settings page, a measured window, models.dev's word) stays the file's. The provider's
|
|
310
|
+
// refusals that another connection to it (a helper) learned meanwhile are kept too.
|
|
297
311
|
const save = () => {
|
|
298
|
-
const
|
|
299
|
-
|
|
300
|
-
|
|
312
|
+
const file = profilePath(client)
|
|
313
|
+
const disk = readJson(file, null)
|
|
314
|
+
if (disk?.model === profile.model) {
|
|
315
|
+
const out = { ...disk, quirks: { ...disk.quirks, ...profile.quirks } }
|
|
316
|
+
for (const k of LEARNED) k in profile ? (out[k] = profile[k]) : delete out[k]
|
|
317
|
+
if (disk.source === 'assumed' && profile.source !== 'assumed') out.source = profile.source // models.dev knows it now
|
|
318
|
+
profile = out
|
|
319
|
+
}
|
|
320
|
+
writeJson(file, profile)
|
|
321
|
+
stamp = stampOf(file)
|
|
322
|
+
settingsSeen('profile') // its own write: not a change to take in
|
|
323
|
+
apply()
|
|
301
324
|
}
|
|
302
325
|
// What the window is, learned while working (context.js); kept in the profile for next time.
|
|
303
326
|
const learned = () => {
|
|
@@ -386,7 +409,8 @@ class App {
|
|
|
386
409
|
'Go back to before which message?',
|
|
387
410
|
turns.map(({ t, i }) => {
|
|
388
411
|
const n = Object.keys(chat.turns.slice(i).reduce((all, x) => Object.assign(all, x.files), {})).length
|
|
389
|
-
|
|
412
|
+
const num = `${i + 1}.`.padStart(String(chat.turns.length).length + 1) // its number in the conversation, newest first
|
|
413
|
+
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
414
|
}),
|
|
391
415
|
)
|
|
392
416
|
if (!pickTurn) return
|
|
@@ -561,10 +585,24 @@ class App {
|
|
|
561
585
|
const sep = c.ghost(' │ ')
|
|
562
586
|
if (!cfg.providers[cfg.provider] || !cfg.model) return c.dim('no model · /settings to choose one')
|
|
563
587
|
const rot = this.s?.client.rotation
|
|
564
|
-
const now =
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
588
|
+
const now = this.inUse() // the model list's one in use; never one of a provider that's off
|
|
589
|
+
if (!now) return c.dim('the providers of all your models are off · /settings to switch one on')
|
|
590
|
+
// provider (providers on) · the model. Auto (rotating your list): "auto (current model) (list
|
|
591
|
+
// size)". A model you picked (pinned, no rotation): just its name.
|
|
592
|
+
const on = Object.values(cfg.providers).filter((p) => !p.off).length
|
|
593
|
+
// one model (pinned): just its name. Several chosen for this session: the one in use + "N chosen".
|
|
594
|
+
// Your whole list (auto): "auto (in use) (N)". ↻ shows the rotation position while rotating.
|
|
595
|
+
const count = this.sessionModels().length
|
|
596
|
+
const spin = rot ? c.faint(` ↻ ${rot.at + 1}/${rot.list.length}`) : ''
|
|
597
|
+
const model =
|
|
598
|
+
count === 1
|
|
599
|
+
? c.info(now.model)
|
|
600
|
+
: cfg.session
|
|
601
|
+
? `${c.info(now.model)} ${c.faint(`(${count} chosen)`)}${spin}`
|
|
602
|
+
: `${c.muted('auto')} ${c.faint('(')}${c.info(now.model)}${c.faint(')')} ${c.faint(`(${count})`)}${spin}`
|
|
603
|
+
const parts = [`${c.brand('●')} ${c.muted(now.provider)} ${c.faint(`(${on})`)} ${c.ghost('·')} ${model}`]
|
|
604
|
+
const key = `${now.provider}/${now.model}`
|
|
605
|
+
if (this.profileKey !== key) [this.profileKey, this.profile] = [key, this.s?.profile ?? loadProfile(this.client(now.provider, now.model))]
|
|
568
606
|
const profile = this.s?.profile ?? this.profile
|
|
569
607
|
if (profile) {
|
|
570
608
|
const strat = this.s?.strat ?? strategy(profile, cfg)
|
|
@@ -627,7 +665,13 @@ class App {
|
|
|
627
665
|
ui.print(NO_MODELS)
|
|
628
666
|
return null
|
|
629
667
|
}
|
|
630
|
-
|
|
668
|
+
// this session's models (a set you chose, else your list), first one whose provider is on
|
|
669
|
+
const start = this.sessionModels()[0]
|
|
670
|
+
if (!start) {
|
|
671
|
+
ui.print(ALL_OFF)
|
|
672
|
+
return null
|
|
673
|
+
}
|
|
674
|
+
const { client, profile, strat } = await this.engine(start.provider, start.model, ui, this.signal)
|
|
631
675
|
this.s = new Session({ client, profile, strat, root: this.root, ui, chat: this.chat }) // details: the status bar
|
|
632
676
|
this.s.autoCompact = cfg.autoCompact !== false
|
|
633
677
|
this.s.onTurn = () => this.save()
|
|
@@ -644,12 +688,12 @@ class App {
|
|
|
644
688
|
return this.s
|
|
645
689
|
}
|
|
646
690
|
|
|
647
|
-
// The
|
|
648
|
-
//
|
|
691
|
+
// The session's models as a rotation on the client: this session's set (sessionModels), then
|
|
692
|
+
// --fallback-model's. One model: no rotation.
|
|
649
693
|
useRotation() {
|
|
650
694
|
const { cfg } = this
|
|
651
695
|
const seen = new Set()
|
|
652
|
-
const list = [
|
|
696
|
+
const list = [...this.sessionModels(), ...(cfg.fallbacks ?? [])]
|
|
653
697
|
.filter((t) => cfg.providers[t.provider] && t.model && !seen.has(`${t.provider}/${t.model}`) && seen.add(`${t.provider}/${t.model}`))
|
|
654
698
|
.map((t) => ({ ...t, baseUrl: cfg.providers[t.provider].baseUrl, apiKey: cfg.providers[t.provider].apiKey }))
|
|
655
699
|
this.s?.client.setRotation(list, cfg.rotation ?? {})
|
|
@@ -666,8 +710,10 @@ class App {
|
|
|
666
710
|
// Your other models (not the one in use) and what each can do, from its saved profile or
|
|
667
711
|
// models.dev: nothing is sent to them to find out.
|
|
668
712
|
team() {
|
|
669
|
-
|
|
670
|
-
|
|
713
|
+
// helpers use this session's models (the set you chose, else your list), minus the one in use —
|
|
714
|
+
// so a pinned single model gives no team, and a chosen set keeps helpers within it
|
|
715
|
+
const now = this.s?.client.rotation?.list[this.s.client.rotation.at] ?? this.sessionModels()[0]
|
|
716
|
+
return this.sessionModels()
|
|
671
717
|
.filter((t) => !(t.provider === now?.provider && t.model === now?.model))
|
|
672
718
|
.map((t) => ({ ...t, ...this.abilities(t) }))
|
|
673
719
|
}
|
|
@@ -693,22 +739,59 @@ class App {
|
|
|
693
739
|
|
|
694
740
|
// Your models, in order: this one (#1), then the fallbacks (chosen on /settings, where every
|
|
695
741
|
// provider's whole list is).
|
|
696
|
-
|
|
742
|
+
// The model answering now: the model list's current one, the session's, or (none yet) the one it
|
|
743
|
+
// will start with: the first of yours whose provider is on (a model given for this run: that one).
|
|
744
|
+
// What the screen shows as "the model": never one of a provider that's switched off.
|
|
745
|
+
inUse() {
|
|
746
|
+
const { cfg } = this
|
|
747
|
+
const rot = this.s?.client.rotation
|
|
748
|
+
if (rot) return rot.list[rot.at]
|
|
749
|
+
if (this.s) return { provider: this.s.client.provider, model: this.s.client.model }
|
|
750
|
+
return this.sessionModels()[0] ?? null
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// your list has models, but the providers of all of them are switched off
|
|
754
|
+
allOff() {
|
|
755
|
+
return !this.cfg.session && this.myModels(true).length > 0 && !this.myModels().length
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// all: also the ones of a provider switched off (on the settings page), which are skipped otherwise
|
|
759
|
+
myModels(all = false) {
|
|
760
|
+
const { cfg } = this
|
|
761
|
+
const seen = new Set()
|
|
762
|
+
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}`))
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// The models THIS session uses: the set you chose with /select_model (cfg.session, session-local),
|
|
766
|
+
// else your whole list. One model: no rotation (pinned). Several: it rotates among just those.
|
|
767
|
+
sessionModels(all = false) {
|
|
697
768
|
const { cfg } = this
|
|
769
|
+
if (!cfg.session) return this.myModels(all)
|
|
698
770
|
const seen = new Set()
|
|
699
|
-
return
|
|
771
|
+
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}`))
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// Use a chosen set of models for this session (session-local, not saved): one pins it (no
|
|
775
|
+
// rotation), several rotate among just those. The saved ranking (settings page / auto) is untouched.
|
|
776
|
+
async setSession(list) {
|
|
777
|
+
const { cfg } = this
|
|
778
|
+
cfg.session = list // only here; cfg.provider/model stay your global #1, so your whole list and the picker are unchanged
|
|
779
|
+
this.s = null
|
|
780
|
+
this.ui.print(list.length === 1 ? `${c.green('using')} ${c.cyan(list[0].model)} ${c.dim('· pinned for this session')}` : `${c.green(`using ${list.length} models this session`)}: ${c.cyan(list.map((t) => t.model).join(', '))}`)
|
|
781
|
+
await this.session()
|
|
700
782
|
}
|
|
701
783
|
|
|
702
|
-
//
|
|
703
|
-
async
|
|
784
|
+
// Back to auto: your whole list again (#1, then the next when one fails), following the saved ranking.
|
|
785
|
+
async auto() {
|
|
704
786
|
const { cfg } = this
|
|
705
|
-
delete cfg.
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
await this.
|
|
787
|
+
delete cfg.session // back to your whole list (cfg.provider/model + the ranking)
|
|
788
|
+
this.s = null
|
|
789
|
+
this.ui.print(`${c.green('using')} ${c.dim('auto')} ${c.cyan(`(${this.myModels().length} models)`)}`)
|
|
790
|
+
await this.session()
|
|
709
791
|
}
|
|
710
792
|
|
|
711
|
-
// The picker:
|
|
793
|
+
// The picker: tick the models for this session — one pins it, several rotate among them, all of
|
|
794
|
+
// them (or none) is the same as auto (your whole list). "auto" is the first entry.
|
|
712
795
|
async chooseModel(filter = '') {
|
|
713
796
|
const { ui, cfg } = this
|
|
714
797
|
const mine = this.myModels()
|
|
@@ -716,20 +799,40 @@ class App {
|
|
|
716
799
|
if (!Object.keys(cfg.providers).length && (await this.detectLocal())) return this.session()
|
|
717
800
|
return ui.print(NO_MODELS)
|
|
718
801
|
}
|
|
719
|
-
const
|
|
720
|
-
const
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
802
|
+
const key = (t) => `${t.provider}/${t.model}`
|
|
803
|
+
const chosen = new Set((cfg.session ?? mine).map(key)) // pre-ticked: this session's set, or all (auto)
|
|
804
|
+
const items = mine.map((t) => ({ label: t.model, t, checked: chosen.has(key(t)), desc: this.modelDesc(t, mine.indexOf(t)) }))
|
|
805
|
+
const picked = await ui.pick('Models for this session — tick several to rotate, one to pin (all = auto)', items, { filter, multi: true })
|
|
806
|
+
if (!picked) return
|
|
807
|
+
const list = picked.map((it) => it.t)
|
|
808
|
+
return list.length >= mine.length ? this.auto() : this.setSession(list)
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// "/select_model a, b, c": use those models this session (one pins, several rotate). One model:
|
|
812
|
+
// "model", "provider model" or "provider/model". "/select_model auto": your whole list. No
|
|
813
|
+
// argument: the picker.
|
|
726
814
|
async pickModel(args) {
|
|
727
815
|
if (!args.length) return this.chooseModel()
|
|
728
|
-
|
|
729
|
-
const
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
816
|
+
if (args.every((a) => isAuto(a))) return this.cfg.session ? this.auto() : this.ui.print(c.dim('already on auto'))
|
|
817
|
+
const raw = args.join(' ')
|
|
818
|
+
const specs = raw.includes(',') ? raw.split(',').map((x) => x.trim()).filter(Boolean) : [raw]
|
|
819
|
+
const resolve = (spec) => {
|
|
820
|
+
const w = spec.trim().split(/\s+/)
|
|
821
|
+
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)
|
|
822
|
+
for (const [name, model] of tries) {
|
|
823
|
+
const hit = this.myModels().find((t) => t.model === model && (!name || t.provider === name))
|
|
824
|
+
if (hit) return hit
|
|
825
|
+
}
|
|
826
|
+
return null
|
|
827
|
+
}
|
|
828
|
+
const found = []
|
|
829
|
+
const missing = []
|
|
830
|
+
for (const spec of specs) (resolve(spec) ? found : missing).push(resolve(spec) ?? spec)
|
|
831
|
+
if (missing.length || !found.length) {
|
|
832
|
+
if (missing.length) this.ui.print(c.dim(`not in your models: ${missing.join(', ')} · add on /settings`))
|
|
833
|
+
return this.chooseModel(specs.length === 1 ? specs[0] : '')
|
|
834
|
+
}
|
|
835
|
+
return this.setSession(found)
|
|
733
836
|
}
|
|
734
837
|
|
|
735
838
|
listProviders() {
|
|
@@ -738,7 +841,7 @@ class App {
|
|
|
738
841
|
if (!names.length) return ui.print(`No providers yet: add one with ${c.cyan('heart settings')}.`)
|
|
739
842
|
for (const n of names) {
|
|
740
843
|
const p = cfg.providers[n]
|
|
741
|
-
const notes = [n ===
|
|
844
|
+
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
845
|
ui.print(`${c.bold(n.padEnd(14))} ${p.baseUrl} ${c.dim(mask(p.apiKey))} ${notes}`)
|
|
743
846
|
}
|
|
744
847
|
}
|
|
@@ -748,13 +851,13 @@ class App {
|
|
|
748
851
|
// provider's models (fetched from each; a provider whose list is off: the ones added by name).
|
|
749
852
|
// json: one JSON object on stdout, for other programs:
|
|
750
853
|
// { "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)
|
|
854
|
+
// "providers": [{ "name", "baseUrl", "list": true, "off"?: true, "models": ["provider/model", …], "error"? }] } (--all)
|
|
752
855
|
async listModels(filter = '', { all = false, json = false } = {}) {
|
|
753
856
|
const { ui, cfg } = this
|
|
754
857
|
const match = (id) => id.toLowerCase().includes(filter.toLowerCase())
|
|
755
858
|
const mine = this.myModels().map((t, i) => ({ id: `${t.provider}/${t.model}`, provider: t.provider, model: t.model, place: i + 1 }))
|
|
756
859
|
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: [] }
|
|
860
|
+
const out = { name, baseUrl: p.baseUrl, list: p.list !== false, ...(p.off && { off: true }), models: [] }
|
|
758
861
|
if (out.list) {
|
|
759
862
|
try {
|
|
760
863
|
out.models = await new Client({ baseUrl: p.baseUrl, apiKey: p.apiKey }).listModels()
|
|
@@ -843,7 +946,8 @@ async function continueTabs(app, saved) {
|
|
|
843
946
|
// A continued tab's own model, as it was (unless its provider is gone: then the current one).
|
|
844
947
|
function useModel(app, { provider, model }) {
|
|
845
948
|
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
|
|
949
|
+
if (!app.cfg.providers[provider]) return app.ui.warn(`this tab used ${provider} · ${model}, but there's no provider "${provider}" now: using your list`)
|
|
950
|
+
if (app.cfg.providers[provider].off) return app.ui.warn(`this tab used ${provider} · ${model}, but ${provider} is switched off: using your list`)
|
|
847
951
|
Object.assign(app.cfg, { provider, model })
|
|
848
952
|
app.s = null
|
|
849
953
|
}
|
|
@@ -872,13 +976,15 @@ function applySettings(apps, cfg, what) {
|
|
|
872
976
|
const busy = Boolean(ui.busySince || app.working)
|
|
873
977
|
const now = (fn) => (busy ? app.later.push(fn) : fn())
|
|
874
978
|
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
|
-
|
|
979
|
+
// a model pinned in this CLI (/select_model) is a session choice: a model/order change on the
|
|
980
|
+
// settings page leaves it alone (only /select_model auto here returns it to the list)
|
|
981
|
+
if (what === 'model' && !app.cfg.session) {
|
|
876
982
|
app.lists = {}
|
|
877
|
-
delete app.cfg.fixed // a model chosen on the page: your list again
|
|
878
983
|
if (app.s && !(cfg.providers[cfg.provider] && cfg.model)) now(() => ((app.s = null), ui.print(c.dim('settings: no model chosen'))))
|
|
984
|
+
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
985
|
}
|
|
880
986
|
// 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) {
|
|
987
|
+
if ((what === 'model' || what === 'setting:rotation') && !app.cfg.session && app.s && cfg.providers[cfg.provider] && cfg.model) {
|
|
882
988
|
const was = `${app.s.client.provider} · ${app.s.client.model}`
|
|
883
989
|
app.useRotation()
|
|
884
990
|
const is = `${app.s.client.provider} · ${app.s.client.model}`
|
|
@@ -907,6 +1013,12 @@ function applySettings(apps, cfg, what) {
|
|
|
907
1013
|
// taken in as the page's own are (applySettings). config.json: the keys others changed (takeIn; a
|
|
908
1014
|
// model given for this run with -m stays unless the list's #1 changes); the other files by their
|
|
909
1015
|
// size and time, and only when this run didn't just apply them itself.
|
|
1016
|
+
// A model's profile fields a running HeartCode learns itself (context.js, the models.dev lookup); the
|
|
1017
|
+
// rest of the file is the settings page's.
|
|
1018
|
+
const LEARNED = ['inputLimit', 'inputLimitAt', 'cutAt', 'tokenRatio', 'largestAccepted', 'rateCap', 'known']
|
|
1019
|
+
// The settings as they are now, into every HeartCode in this process (watchSettings): run before each
|
|
1020
|
+
// request too, so a change the file watcher hasn't passed on yet (or never will) is still taken in.
|
|
1021
|
+
let syncSettings = () => {}
|
|
910
1022
|
const WATCHED = { 'mcp.json': 'mcp', 'hooks.json': 'hooks', 'keybindings.json': 'keys', 'theme.json': 'theme', models: 'profile', prompts: 'prompts', 'HEARTCODE.md': 'prompts', skills: 'skill' }
|
|
911
1023
|
const seenStamp = {}
|
|
912
1024
|
const stampOf = (p) => {
|
|
@@ -930,6 +1042,7 @@ function watchSettings(apps, cfg) {
|
|
|
930
1042
|
}
|
|
931
1043
|
let timer = null
|
|
932
1044
|
const check = () => {
|
|
1045
|
+
clearTimeout(timer)
|
|
933
1046
|
timer = null
|
|
934
1047
|
configChanged()
|
|
935
1048
|
for (const [name, what] of Object.entries(WATCHED)) {
|
|
@@ -940,6 +1053,7 @@ function watchSettings(apps, cfg) {
|
|
|
940
1053
|
applySettings(apps(), cfg, what)
|
|
941
1054
|
}
|
|
942
1055
|
}
|
|
1056
|
+
syncSettings = check
|
|
943
1057
|
try {
|
|
944
1058
|
const watcher = fs.watch(HOME, { recursive: true }, (_, file) => {
|
|
945
1059
|
const top = String(file ?? '').split(path.sep)[0]
|
|
@@ -991,14 +1105,6 @@ function leaveTabWorktree(app) {
|
|
|
991
1105
|
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
1106
|
}
|
|
993
1107
|
|
|
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
1108
|
// A model's profile: the saved one; else what models.dev says (nothing is sent to the model to
|
|
1003
1109
|
// find out); a model it doesn't know: plain assumptions, until you set it up on the settings page.
|
|
1004
1110
|
// force (/test_model, heart probe): test it now instead, with real requests.
|
|
@@ -1024,7 +1130,7 @@ async function getProfile(client, ui, force, signal, quiet = false) {
|
|
|
1024
1130
|
const t0 = Date.now()
|
|
1025
1131
|
const tested = await probe(client, (label) => ui.spinner(`testing: ${label}`), signal)
|
|
1026
1132
|
ui.stopSpin()
|
|
1027
|
-
ui.print(c.dim(`done in ${(
|
|
1133
|
+
ui.print(c.dim(`done in ${duration(Date.now() - t0)} · saved to ${profilePath(client)}`))
|
|
1028
1134
|
return tested
|
|
1029
1135
|
}
|
|
1030
1136
|
|
|
@@ -1038,6 +1144,8 @@ async function slash(app, line) {
|
|
|
1038
1144
|
const [cmd, ...args] = line.slice(1).trim().split(/\s+/)
|
|
1039
1145
|
const { ui, cfg } = app
|
|
1040
1146
|
switch (cmd) {
|
|
1147
|
+
case 'close_tab':
|
|
1148
|
+
return void (await app.closeTab())
|
|
1041
1149
|
case 'exit':
|
|
1042
1150
|
case 'quit': {
|
|
1043
1151
|
const n = app.tabs?.list.length ?? 1
|
|
@@ -1123,7 +1231,7 @@ async function slash(app, line) {
|
|
|
1123
1231
|
const format = /\.html?$/i.test(file) ? 'html' : /\.json$/i.test(file) ? 'json' : 'md'
|
|
1124
1232
|
const abs = path.resolve(app.root, file)
|
|
1125
1233
|
fs.mkdirSync(path.dirname(abs), { recursive: true })
|
|
1126
|
-
fs.writeFileSync(abs, exportChat(app.chat, format,
|
|
1234
|
+
fs.writeFileSync(abs, exportChat(app.chat, format, app.inUse() ? `${app.inUse().provider} · ${app.inUse().model}` : ''))
|
|
1127
1235
|
return ui.print(`${c.green('saved')} ${path.relative(app.root, abs) || abs}`)
|
|
1128
1236
|
}
|
|
1129
1237
|
case 'diff': {
|
|
@@ -1164,8 +1272,8 @@ async function slash(app, line) {
|
|
|
1164
1272
|
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
1273
|
return ui.print(
|
|
1166
1274
|
u.priced
|
|
1167
|
-
? `${c.bold('cost')} ${money(u.cost)}${price ? c.faint(` (${
|
|
1168
|
-
: c.faint(`cost: not known for ${
|
|
1275
|
+
? `${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)`) : ''}`
|
|
1276
|
+
: c.faint(`cost: not known for ${app.inUse()?.model ?? 'this model'} (models.dev doesn't list it; local and subscription models have none)`),
|
|
1169
1277
|
)
|
|
1170
1278
|
}
|
|
1171
1279
|
case 'clear_chat': // a new session; the old one stays in /resume
|
|
@@ -1294,17 +1402,31 @@ async function repl(app, f = {}) {
|
|
|
1294
1402
|
ui.close()
|
|
1295
1403
|
process.exit(0)
|
|
1296
1404
|
}
|
|
1297
|
-
// Ctrl+Q: this tab (working:
|
|
1298
|
-
let
|
|
1299
|
-
|
|
1300
|
-
if (
|
|
1301
|
-
|
|
1302
|
-
|
|
1405
|
+
// Ctrl+Q, /close_tab: this tab, after asking (working: that stops too); the last one quits
|
|
1406
|
+
let closing = false
|
|
1407
|
+
app.closeTab = async (working = false) => {
|
|
1408
|
+
if (closing) return
|
|
1409
|
+
closing = true
|
|
1410
|
+
try {
|
|
1411
|
+
const last = (app.tabs?.list.length ?? 1) < 2
|
|
1412
|
+
const go = await ui.pick(`Close this tab?${working ? ' It is still working: that stops too.' : ''}`, [
|
|
1413
|
+
{ label: 'Yes, close it', desc: last ? 'the last tab: HeartCode quits (it comes back next time)' : undefined, yes: true },
|
|
1414
|
+
{ label: 'No' },
|
|
1415
|
+
])
|
|
1416
|
+
if (!go?.yes) return
|
|
1417
|
+
if (last && app.tabs) app.tabs.quitting = true
|
|
1418
|
+
ctl?.abort()
|
|
1419
|
+
ui.cancelAsk()
|
|
1420
|
+
ui.close()
|
|
1421
|
+
} finally {
|
|
1422
|
+
closing = false
|
|
1303
1423
|
}
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
ui.
|
|
1424
|
+
}
|
|
1425
|
+
ui.onCloseTab = () => {
|
|
1426
|
+
if (closing) return
|
|
1427
|
+
if (!ui.asking) return app.closeTab(true) // working: asked over the work
|
|
1428
|
+
if (!ui.histOn || ui.menu.pick) return ui.notice('Answer this first, then Ctrl+Q', 2000)
|
|
1429
|
+
ui.submit('/close_tab') // at the prompt: asked in its turn, like a typed /close_tab
|
|
1308
1430
|
}
|
|
1309
1431
|
const others = () => (app.tabs?.list.length ?? 1) - 1
|
|
1310
1432
|
ui.onInterrupt = () => {
|
|
@@ -1328,7 +1450,7 @@ async function repl(app, f = {}) {
|
|
|
1328
1450
|
const where = home && app.root.startsWith(home) ? '~' + app.root.slice(home.length) : app.root
|
|
1329
1451
|
ui.header = (cols = 100) => [
|
|
1330
1452
|
`${c.brand('♥')} ${c.bright(c.bold('HeartCode'))} ${c.faint(VERSION)} ${c.muted(where.length > cols - 16 ? '…' + where.slice(-(cols - 17)) : where)}`,
|
|
1331
|
-
[['/', '
|
|
1453
|
+
[['/', '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
1454
|
'',
|
|
1333
1455
|
]
|
|
1334
1456
|
if (!ui.full) ui.print(ui.header().join('\n'))
|
|
@@ -1376,6 +1498,7 @@ async function repl(app, f = {}) {
|
|
|
1376
1498
|
ui.setPermission(go.mode)
|
|
1377
1499
|
ok = await runTask(app, prompt('agent/plan-approved'), ctl)
|
|
1378
1500
|
}
|
|
1501
|
+
if (ui.busySince) ui.print(c.faint(c.italic(`✻ took ${duration(ui.worked())}`))) // fixed once the reply is in
|
|
1379
1502
|
}
|
|
1380
1503
|
ui.idle()
|
|
1381
1504
|
ctl = null
|
|
@@ -1507,6 +1630,11 @@ async function main() {
|
|
|
1507
1630
|
// Everything is allowed unless the user chose otherwise (saved), or --ask for this run.
|
|
1508
1631
|
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
1632
|
if (!headless) {
|
|
1633
|
+
ui.queueMode = cfg.queue ?? 'all'
|
|
1634
|
+
ui.onQueueMode = (mode) => {
|
|
1635
|
+
cfg.queue = mode
|
|
1636
|
+
saveConfig(cfg)
|
|
1637
|
+
}
|
|
1510
1638
|
ui.onPermission = (mode) => {
|
|
1511
1639
|
if (mode === 'plan') return // for this session only: HeartCode doesn't start in plan mode
|
|
1512
1640
|
cfg.permissions = mode
|