heartcode 0.1.24 → 0.1.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  What changed in each version of HeartCode, newest first.
4
4
 
5
+ ## 0.1.25 (2026-10-31)
6
+
7
+ - The model(s) you choose for a folder with /select_model are remembered per folder, in
8
+ .heartcode/models.json, and restored when you open that folder again; auto stays auto (the file
9
+ is removed). A -m model on the command line still wins for that run.
10
+ - The status bar and the working spinner use two lines instead of cramming everything onto one: the
11
+ status shows the model and what it can do on one line, the run's stats and controls on the next;
12
+ the spinner shows what it's doing, with the elapsed time and Esc on the line below.
13
+ - When a chosen set or auto rotates to a fallback (the model answering now isn't your first/preferred
14
+ one, because it failed), the status bar marks that model with a yellow "↻" so you can see at a
15
+ glance it came after rotating. No rotation, or back on the first model: no marker.
16
+
5
17
  ## 0.1.24 (2026-10-31)
6
18
 
7
19
  - A pinned model (one picked with /select_model, or -m) is now used by the helpers it spawns too:
package/bin/heartcode.js CHANGED
@@ -168,6 +168,23 @@ class App {
168
168
  this.director = new Director(this) // the manage tool: skills, MCP servers and settings by asking
169
169
  this.later = [] // setting changes that wait until the current message is done
170
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
171
188
  }
172
189
 
173
190
  newMcp() {
@@ -593,39 +610,42 @@ class App {
593
610
  // one model (pinned): just its name. Several chosen for this session: the one in use + "N chosen".
594
611
  // Your whole list (auto): "auto (in use) (N)". ↻ shows the rotation position while rotating.
595
612
  const count = this.sessionModels().length
596
- const spin = rot ? c.faint(` ↻ ${rot.at + 1}/${rot.list.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)
597
617
  const model =
598
618
  count === 1
599
- ? c.info(now.model)
619
+ ? name
600
620
  : 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}`]
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 = []
604
626
  const key = `${now.provider}/${now.model}`
605
627
  if (this.profileKey !== key) [this.profileKey, this.profile] = [key, this.s?.profile ?? loadProfile(this.client(now.provider, now.model))]
606
628
  const profile = this.s?.profile ?? this.profile
607
629
  if (profile) {
608
630
  const strat = this.s?.strat ?? strategy(profile, cfg)
609
- parts.push(c.muted(strat.actions === 'tools' ? 'tools' : 'guided'))
610
- if (strat.thinking) parts.push(c.think(`thinking${cfg.effort ? ` · ${cfg.effort}` : ''}`) + (ui.showThinking ? '' : c.faint(' (collapsed)')))
611
- 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'))
612
634
  const used = this.s?.client.lastTokens ?? 0
613
635
  const pct = Math.round((used / strat.context) * 100)
614
636
  const paint = pct >= 80 ? c.red : pct >= 50 ? c.yellow : c.green
615
637
  const guess = strat.contextSure ? '' : '~' // not said by the server: assumed, and growing as bigger requests work
616
- 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}%`)}`)
617
639
  const tps = this.s?.client.tps
618
- if (tps) parts.push(`${c.muted(String(tps))} ${c.faint('tok/s')}`)
619
- 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)))
620
642
  } else {
621
- parts.push(c.dim('not probed yet'))
643
+ first.push(c.dim('not probed yet'))
622
644
  }
623
- if (cfg.autoCompact === false) parts.push(c.faint('auto-compact off'))
624
- if (this.worktree) parts.push(c.info(`⎇ ${this.worktree.branch}`))
625
-
626
- 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]
627
- parts.push(perm) // Shift+Tab changes it (see the header)
628
- 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')
629
649
  }
630
650
 
631
651
  client(name, model) {
@@ -771,20 +791,23 @@ class App {
771
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}`))
772
792
  }
773
793
 
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.
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.
776
796
  async setSession(list) {
777
797
  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
798
+ cfg.session = list // the global #1 (cfg.provider/model) stays, so your whole list and the picker are unchanged
799
+ this.saveProjectModels()
779
800
  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(', '))}`)
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(', '))}`)
781
802
  await this.session()
782
803
  }
783
804
 
784
- // Back to auto: your whole list again (#1, then the next when one fails), following the saved ranking.
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.
785
807
  async auto() {
786
808
  const { cfg } = this
787
- delete cfg.session // back to your whole list (cfg.provider/model + the ranking)
809
+ delete cfg.session
810
+ this.saveProjectModels()
788
811
  this.s = null
789
812
  this.ui.print(`${c.green('using')} ${c.dim('auto')} ${c.cyan(`(${this.myModels().length} models)`)}`)
790
813
  await this.session()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "heartcode",
3
- "version": "0.1.24",
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",
package/src/ui.js CHANGED
@@ -885,15 +885,14 @@ export class UI {
885
885
  const hiddenBelow = input.rows.length - first - box.length
886
886
  const rule = c.ghost('─'.repeat(cols))
887
887
  const note = (n, arrow) => c.ghost('── ') + c.faint(`${arrow} ${n} more line${n > 1 ? 's' : ''}`) + c.ghost(` ${'─'.repeat(Math.max(0, cols - 18 - String(n).length))}`)
888
- const below = [hiddenBelow ? note(hiddenBelow, '↓') : rule, ...(this.status ? [fit(' ' + this.status(), cols)] : [])] // under the input
888
+ const below = [hiddenBelow ? note(hiddenBelow, '↓') : rule, ...(this.status ? this.status().split('\n').map((l) => fit(' ' + l, cols)) : [])] // the status bar, one line or more
889
889
  const bottom = []
890
890
  if (this.timer && !this.asking) {
891
- // how long it's been at this message, and (while waiting on the model) since this request was sent
891
+ // the spinner on its own line; the timer (and Esc) on the next, so neither line is crammed
892
892
  const request = this.requestSince?.()
893
893
  const timer = this.busySince ? `responding ${duration(this.worked())}${request ? ` · waiting for model ${duration(Date.now() - request)}` : ''} · Esc to stop` : ''
894
- const retrying = this.beforeRetry !== undefined // the label is the retry message: its own line, timer below
895
- bottom.push(fit(`${c.brand(FRAMES[this.frame % FRAMES.length])} ${c.muted(this.#label())}${retrying || !timer ? '' : c.faint(` · ${timer}`)}`, cols))
896
- if (retrying && timer) bottom.push(fit(` ${c.faint(timer)}`, cols))
894
+ bottom.push(fit(`${c.brand(FRAMES[this.frame % FRAMES.length])} ${c.muted(this.#label())}`, cols))
895
+ if (timer) bottom.push(fit(` ${c.faint(timer)}`, cols))
897
896
  }
898
897
  if (this.scroll) bottom.push(c.dim(` ↓ ${this.scroll} more lines below · scroll down, Shift+↓ or PgDn`))
899
898
  if (this.ahead.length) {