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/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', 'switch to another of your models (the list, and every provider\'s models: /settings)', true],
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 (or press Ctrl+R in the input): it goes in the input box'],
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: ${cfg.provider ?? '(none)'} ${cfg.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
- // Saved with what another connection to the same model (a helper on it) learned meanwhile: the
296
- // provider's refusals of both are kept (each writes its whole profile).
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 disk = readJson(profilePath(client), null)
299
- if (disk?.model === profile.model) profile.quirks = { ...disk.quirks, ...profile.quirks }
300
- writeJson(profilePath(client), profile)
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
- return { label: t.task.split('\n')[0].slice(0, 70), desc: `${ago(t.at)}${n ? ` · ${n} file${n > 1 ? 's' : ''} changed since` : ''}`, i }
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 = rot?.list[rot.at] ?? { provider: cfg.provider, model: cfg.model } // the model list: the one in use
565
- const parts = [`${c.brand('●')} ${c.muted(now.provider)} ${c.ghost('·')} ${c.info(now.model)}${rot ? c.faint(` ↻ ${rot.at + 1}/${rot.list.length}`) : ''}`]
566
- const key = `${cfg.provider}/${cfg.model}`
567
- if (this.profileKey !== key) [this.profileKey, this.profile] = [key, this.s?.profile ?? loadProfile(this.client(cfg.provider, cfg.model))]
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
- const { client, profile, strat } = await this.engine(cfg.provider, cfg.model, ui, this.signal)
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 model list (settings page): this model first, then the others in order; set on the session.
648
- // A model given for this run (-m, not "auto") is used alone (cfg.fixed), then --fallback-model's.
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 = [{ provider: cfg.provider, model: cfg.model }, ...(cfg.fixed ? [] : (cfg.rotation?.then ?? [])), ...(cfg.fallbacks ?? [])]
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
- const now = this.s?.client.rotation?.list[this.s.client.rotation.at] ?? this.myModels()[0]
670
- return this.myModels()
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
- myModels() {
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 [{ 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}`))
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
- // Makes one of your models #1 (the rest keep their order); saved for next time.
703
- async useMine(t) {
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.fixed // one of your models: your list again, with its fallbacks
706
- const rest = this.myModels().filter((x) => x.provider !== t.provider || x.model !== t.model)
707
- cfg.rotation = { ...cfg.rotation, then: rest }
708
- await this.use(t.provider, t.model)
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: your models only, #1 first; Enter makes it #1.
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 items = mine.map((t, i) => ({ label: t.model, t, current: i === 0, desc: this.modelDesc(t, i) }))
720
- const it = await ui.pick('Choose a model (more on /settings)', items, { filter })
721
- if (it && !it.current) await this.useMine(it.t)
722
- }
723
-
724
- // "/select_model gpt-oss-120b" or "/select_model groq openai/gpt-oss-120b": one of your models;
725
- // no argument, or one that's not in your list: the picker.
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
- const [name, model] = args.length > 1 ? [args[0], args.slice(1).join(' ')] : [null, args[0]]
729
- const found = this.myModels().filter((t) => t.model === model && (!name || t.provider === name))
730
- 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])
731
- if (!found.length) this.ui.print(c.dim(`${model} isn't one of your models: add it on /settings`))
732
- return this.chooseModel(found.length ? model : '')
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 === cfg.provider && c.magenta('● current'), n === cfg.adhoc && c.dim('this run only')].filter(Boolean).join(' ')
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 ${app.cfg.provider} · ${app.cfg.model}`)
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
- if (what === 'model') {
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 ${((Date.now() - t0) / 1000).toFixed(1)}s · saved to ${profilePath(client)}`))
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, cfg.model ? `${cfg.provider} · ${cfg.model}` : ''))
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(` (${cfg.model}: $${price.input ?? 0} in / $${price.output ?? 0} out per million tokens, from models.dev)`) : ''}`
1168
- : c.faint(`cost: not known for ${cfg.model ?? 'this model'} (models.dev doesn't list it; local and subscription models have none)`),
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: press it again to stop the work too); the last one quits
1298
- let lastClose = 0
1299
- ui.onCloseTab = () => {
1300
- if (ctl && Date.now() - lastClose > 2000) {
1301
- lastClose = Date.now()
1302
- return ui.notice('Still working: Ctrl+Q again stops it and closes this tab', 2000)
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
- if (app.tabs.list.length < 2) app.tabs.quitting = true
1305
- ctl?.abort()
1306
- ui.cancelAsk()
1307
- ui.close()
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
- [['/', '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(' · ')),
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "heartcode",
3
- "version": "0.1.14",
3
+ "version": "0.1.24",
4
4
  "description": "A coding CLI for any local or OpenAI-compatible model, including small models without tool calling.",
5
5
  "keywords": [
6
6
  "ai",