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/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 = {
@@ -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: ${cfg.provider ?? '(none)'} ${cfg.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
- // 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).
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 disk = readJson(profilePath(client), null)
299
- if (disk?.model === profile.model) profile.quirks = { ...disk.quirks, ...profile.quirks }
300
- writeJson(profilePath(client), profile)
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
- return { label: t.task.split('\n')[0].slice(0, 70), desc: `${ago(t.at)}${n ? ` · ${n} file${n > 1 ? 's' : ''} changed since` : ''}`, i }
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 = 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))]
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
- parts.push(c.muted(strat.actions === 'tools' ? 'tools' : 'guided'))
572
- if (strat.thinking) parts.push(c.think(`thinking${cfg.effort ? ` · ${cfg.effort}` : ''}`) + (ui.showThinking ? '' : c.faint(' (collapsed)')))
573
- if (strat.vision) parts.push(c.muted('images'))
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
- parts.push(`${c.faint('ctx')} ${paint(`${kTokens(used)}/${guess}${kTokens(strat.context)}`)} ${c.faint(`${pct}%`)}`)
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) parts.push(`${c.muted(String(tps))} ${c.faint('tok/s')}`)
581
- if (this.chat.usage?.priced) parts.push(c.muted(money(this.chat.usage.cost)))
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
- parts.push(c.dim('not probed yet'))
643
+ first.push(c.dim('not probed yet'))
584
644
  }
585
- if (cfg.autoCompact === false) parts.push(c.faint('auto-compact off'))
586
- if (this.worktree) parts.push(c.info(`⎇ ${this.worktree.branch}`))
587
-
588
- const perm = { all: c.green('⏵⏵ all allowed'), commands: c.yellow('ask before commands'), ask: c.yellow('ask before changes'), plan: c.info('⏸ plan mode') }[ui.permission]
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
- const { client, profile, strat } = await this.engine(cfg.provider, cfg.model, ui, this.signal)
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 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.
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 = [{ provider: cfg.provider, model: cfg.model }, ...(cfg.fixed ? [] : (cfg.rotation?.then ?? [])), ...(cfg.fallbacks ?? [])]
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
- const now = this.s?.client.rotation?.list[this.s.client.rotation.at] ?? this.myModels()[0]
670
- return this.myModels()
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
- myModels() {
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
- // Makes one of your models #1 (the rest keep their order); saved for next time.
703
- async useMine(t) {
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
- 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)
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
- // The picker: your models only, #1 first; Enter makes it #1.
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 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.
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
- 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 : '')
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 === cfg.provider && c.magenta('● current'), n === cfg.adhoc && c.dim('this run only')].filter(Boolean).join(' ')
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 ${app.cfg.provider} · ${app.cfg.model}`)
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
- if (what === 'model') {
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 ${((Date.now() - t0) / 1000).toFixed(1)}s · saved to ${profilePath(client)}`))
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, cfg.model ? `${cfg.provider} · ${cfg.model}` : ''))
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(` (${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)`),
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: 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)
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
- if (app.tabs.list.length < 2) app.tabs.quitting = true
1305
- ctl?.abort()
1306
- ui.cancelAsk()
1307
- ui.close()
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
- [['/', '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(' · ')),
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "heartcode",
3
- "version": "0.1.15",
3
+ "version": "0.1.25",
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",