aegiscode 6.2.0 → 6.3.1

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/README.md CHANGED
@@ -48,12 +48,50 @@ export AEGIS_API_KEY="aegis_..."
48
48
 
49
49
  ```bash
50
50
  aegiscode # interactive session
51
+ aegiscode --continue # skip onboarding, resume the last session
51
52
  aegiscode "why is the sky blue" # one-shot, prints the answer and the tokens
52
53
  aegiscode -p "..." --json # machine-readable
53
54
  echo "q" | aegiscode -p - # prompt on stdin
54
55
  aegiscode -m deepseek/deepseek-v4-flash -p "..." # pin a model
55
56
  ```
56
57
 
58
+ ## First run
59
+
60
+ A genuine first run walks the reference's onboarding before the session starts —
61
+ the order is `aegiscodex-dev`'s:
62
+
63
+ ```
64
+ ────────────────────────────────────────────────────────────────────────────────
65
+ Accessing workspace:
66
+ /home/you/project
67
+
68
+ Quick safety check: Is this a project you created or one you trust? …
69
+ AEGIS Code will be able to read, edit, and execute files here.
70
+
71
+ Security guide
72
+
73
+ ❯ 1.Yes, I trust this folder
74
+ 2.No, exit
75
+
76
+ Enter to confirm · Esc to cancel
77
+ ```
78
+
79
+ then the theme picker (the reference's 7 rows — Auto, Dark/Light, plus
80
+ colourblind-friendly and ANSI-only variants, each previewed as a real diff in
81
+ that palette), then the welcome screen with the mark, a **Tips for getting
82
+ started** box and a **What's new** box. The chosen row is written to
83
+ `~/.aegiscode/config.json`, so `Welcome back!` is what you get next time and the
84
+ picker never reappears.
85
+
86
+ Declining the trust check **ends the process** rather than continuing — the
87
+ folder you just refused to vouch for is not read, edited or executed in.
88
+ `--continue` skips onboarding entirely.
89
+
90
+ Preferences survive a restart: model, effort, theme and vim mode are restored on
91
+ launch, and an explicit flag (`-m`, `--light`) always outranks the stored value.
92
+
93
+ ## Use
94
+
57
95
  In a session, plain text is a prompt. `/help` lists commands, grouped by
58
96
  category, the way `aegiscodex-dev` does. The registry is that client's, ported
59
97
  command for command — 75 entries across nine categories:
@@ -93,7 +131,7 @@ alternate-screen frame of header rule, transcript viewport, spinner/effort line,
93
131
  input line and status line, driven by a raw key stream.
94
132
 
95
133
  ```
96
- ╭───────────────────────────── AEGIS Code v6.2.0 ─────────────────────────────╮
134
+ ╭───────────────────────────── AEGIS Code v6.3.0 ─────────────────────────────╮
97
135
  ❯ summarise what changed in the token accounting
98
136
  ● Three things changed, and one of them was costing you money:
99
137
 
@@ -138,9 +176,14 @@ What the loop does, in the order a turn happens:
138
176
  transcript live (and scrolling up is anchored to an absolute line index, so an
139
177
  arriving delta does not drag the reader back to the bottom); everything else
140
178
  you type is replayed into the input line afterwards.
141
- - **Overlays**: `/` the palette, `alt+p` the model picker, `/effort` the effort
142
- picker, `/resume` the session list, `?` the shortcut grid, and a centred
143
- Yes/No dialog when a mutating tool needs approval.
179
+ - **Overlays**: `/` the palette (fuzzy-ranked, with `Tab` to complete to the
180
+ highlighted command), `alt+p` the model picker, `/effort` the effort picker,
181
+ `/resume` the session list, `?` the shortcut grid, `ctrl+o` permissions, and a
182
+ centred Yes/No dialog when a mutating tool needs approval. `Esc` closes an
183
+ overlay, and clears the input line when nothing is open.
184
+ - **Every turn is persisted** to `~/.aegiscode/history.jsonl`, with a transcript
185
+ checkpoint alongside it, so `/resume`, `/cost`, `/clear` and `/rewind` all have
186
+ something real to read. On exit the session prints how to come back to it.
144
187
 
145
188
  Anything that is not a real terminal — a pipe, `-p`, a CI run — stays a linear
146
189
  transcript written once to scrollback, so output remains pipeable and
package/bin/aegiscode.js CHANGED
@@ -35,6 +35,7 @@ Options:
35
35
  --width <cols> force a render width (useful for piping/logs)
36
36
  --yolo skip tool-approval prompts (exec/writeFile/editFile
37
37
  run without asking) — same as the in-session /yolo
38
+ -c, --continue skip onboarding and resume the most recent session
38
39
  -h, --help this text
39
40
  -v, --version print the version
40
41
 
@@ -54,6 +55,7 @@ function parseArgs(argv) {
54
55
  light: false,
55
56
  width: null,
56
57
  yolo: false,
58
+ continue: false,
57
59
  prompt: null,
58
60
  help: false,
59
61
  version: false,
@@ -109,6 +111,10 @@ function parseArgs(argv) {
109
111
  case '--yolo':
110
112
  opts.yolo = true;
111
113
  break;
114
+ case '-c':
115
+ case '--continue':
116
+ opts.continue = true;
117
+ break;
112
118
  case '--width':
113
119
  opts.width = Number(next());
114
120
  break;
@@ -180,6 +186,7 @@ async function main(argv = process.argv.slice(2)) {
180
186
  width: opts.width ? () => opts.width : undefined,
181
187
  interactive: !prompt && Boolean(process.stdin.isTTY),
182
188
  confirmMode: !opts.yolo,
189
+ continue: opts.continue,
183
190
  });
184
191
 
185
192
  if (prompt) return app.runOnce(prompt, { json: opts.json });
@@ -190,7 +197,12 @@ async function main(argv = process.argv.slice(2)) {
190
197
  );
191
198
  return 2;
192
199
  }
193
- return app.runInteractive();
200
+ const code = await app.runInteractive();
201
+ // The reference leaves a resume hint on exit; without one there is no way to
202
+ // discover that a session was persisted at all (`/resume` lists them, but a
203
+ // user who just quit is not looking at a command list).
204
+ process.stdout.write(`\nResume this session with:\n aegiscode --continue (or /resume for the list)\n`);
205
+ return code;
194
206
  }
195
207
 
196
208
  if (require.main === module) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "aegiscode",
3
3
  "productName": "AEGIS Code",
4
- "version": "6.2.0",
4
+ "version": "6.3.1",
5
5
  "description": "aegiscode — the command-line version of AEGIS Desktop. The shared tool surface in your shell, over the same thin transport and tool registry as the MCP plugin and the desktop app. Ships transport + UI only; no brain.",
6
6
  "author": {
7
7
  "name": "AEGIS Code",
package/src/app.js CHANGED
@@ -23,11 +23,14 @@ const os = require('node:os');
23
23
  const { randomUUID } = require('node:crypto');
24
24
  const { createTools, createClient, usageTokens } = require('./deps.js');
25
25
  const { createEngine } = require('./engine.js');
26
- const { GLYPH, VERBS, themeOf, RESET } = require('./theme.js');
26
+ const { GLYPH, VERBS, themeOf, RESET, THEME_TABLE } = require('./theme.js');
27
27
  const { LiveRegion, termWidth, w } = require('./screen.js');
28
28
  const { parseLine, COMMANDS, visibleCommands } = require('./commands.js');
29
- const { updateConfig, loadPermissions } = require('./config.js');
30
- const { readSessionTranscript } = require('./history.js');
29
+ const { updateConfig, loadPermissions, loadConfig, configExists } = require('./config.js');
30
+ const { normalizeModelCatalog, pickerEntries, catalogIds } = require('./models.js');
31
+ const { appendHistory, readSessionTranscript, readOwnSessions } = require('./history.js');
32
+ const { snapshotCheckpoint } = require('./checkpoint.js');
33
+ const screens = require('./screens.js');
31
34
  const chatflow = require('./chatflow.js');
32
35
  const overlays = require('./overlays.js');
33
36
  const render = require('./render.js');
@@ -86,7 +89,7 @@ function createApp(options = {}) {
86
89
  model: opts.model,
87
90
  effort: 'high',
88
91
  thinking: false,
89
- themeIndex: opts.light ? 0 : 1,
92
+ themeIndex: opts.light ? 2 : 1,
90
93
  vim: false,
91
94
  stream: opts.stream,
92
95
  cwd: process.cwd(),
@@ -144,6 +147,71 @@ function createApp(options = {}) {
144
147
  }
145
148
  }
146
149
 
150
+ // ── the AEGIS Cloud model catalog ──────────────────────────────────────────
151
+ //
152
+ // `/model` (and the alt+p chord that dispatches it) reads the pinnable ids
153
+ // from the server, and `state().models` is where it looks. That path had no
154
+ // source at all: `commands.js` calls `c.loadModels()` inside a `try {} catch
155
+ // {}`, `c.loadModels` was never defined on either command context, and
156
+ // `buildState()` hardcoded `models: []` — so the TypeError was swallowed and
157
+ // both `/model` and the picker reported "no models advertised" on a perfectly
158
+ // healthy account, forever. The ids themselves are the server's (see
159
+ // models.js), fetched here and cached, because the pool adds and retires
160
+ // providers without a client release.
161
+ const MODEL_CACHE_MS = 5 * 60_000;
162
+ const modelCache = { at: 0, models: [] };
163
+
164
+ /**
165
+ * Fetch (or return the cached) model catalog. Rejects when the account cannot
166
+ * read it at all — no key, offline, or a server error — which callers treat as
167
+ * "nothing to offer" rather than retrying per keystroke.
168
+ * @returns {Promise<Array<{id:string,label:string,note:string}>>}
169
+ */
170
+ async function loadModels({ force = false } = {}) {
171
+ const fresh = modelCache.models.length && Date.now() - modelCache.at < MODEL_CACHE_MS;
172
+ if (!force && fresh) return modelCache.models;
173
+ const data = await client.listModels();
174
+ modelCache.models = normalizeModelCatalog(data && data.models);
175
+ modelCache.at = Date.now();
176
+ return modelCache.models;
177
+ }
178
+
179
+ /**
180
+ * A pinned model id the server does not advertise is a *silent* fallback: the
181
+ * pool answers from its own default with no error, so the pin looks honoured
182
+ * while the reply came from another model — and the cost is attributed to the
183
+ * model that was pinned. Two shipped defaults did exactly that: `sonnet`
184
+ * (written into config.json by onboarding, merged in from DEFAULT_CONFIG, and
185
+ * never advertised by AEGIS Cloud) and any `provider/model` spelling a user
186
+ * typed by hand. Clears such a pin once, says so, and leaves the server's own
187
+ * default in its place.
188
+ *
189
+ * Best-effort and offline-safe: a catalog that cannot be read clears nothing.
190
+ */
191
+ async function validatePinnedModel() {
192
+ const pinned = commandCtx.model;
193
+ if (!pinned) return null;
194
+ let models;
195
+ try {
196
+ models = await loadModels();
197
+ } catch {
198
+ return null;
199
+ }
200
+ if (!models.length) return null;
201
+ if (catalogIds(models).has(String(pinned).toLowerCase())) return null;
202
+ commandCtx.model = null;
203
+ updateConfig({ model: null, currentModelId: null });
204
+ emit(
205
+ render.renderNotice(
206
+ ctx(),
207
+ 'warn',
208
+ `pinned model "${pinned}" is not advertised by AEGIS Cloud — pin cleared, ` +
209
+ 'the pool will choose; /models lists what you can pin.'
210
+ )
211
+ );
212
+ return pinned;
213
+ }
214
+
147
215
  function bannerLines() {
148
216
  return render.renderBanner(ctx(), {
149
217
  width: width(),
@@ -382,6 +450,7 @@ function createApp(options = {}) {
382
450
  try {
383
451
  res = await ask(prompt, { history });
384
452
  } catch (e) {
453
+ persistTurn(prompt, { text: '', error: e.message }, 'error');
385
454
  emit(render.renderTurn(ctx(), { role: 'error', text: e.message }, width()));
386
455
  return;
387
456
  }
@@ -389,6 +458,7 @@ function createApp(options = {}) {
389
458
  if (res.text) transcript.push({ role: 'assistant', text: res.text });
390
459
 
391
460
  const tokens = recordTurn(res);
461
+ persistTurn(prompt, res, res.interrupted ? 'stopped' : res.error ? 'error' : 'done');
392
462
 
393
463
  const spend = await refreshSpend();
394
464
 
@@ -513,7 +583,9 @@ function createApp(options = {}) {
513
583
  plan: session.plan || null,
514
584
  account: session.account || null,
515
585
  permissions: { mode: rules.defaultMode, rules },
516
- models: [],
586
+ // The selectable (pickable) catalog, not the raw payload: alias tiers are
587
+ // dropped, live ids only (see models.js pickerEntries).
588
+ models: pickerEntries(modelCache.models),
517
589
  commands: visibleCommands(),
518
590
  transcript: transcript.slice(),
519
591
  sessions: [],
@@ -598,10 +670,19 @@ function createApp(options = {}) {
598
670
  }
599
671
  }
600
672
 
673
+ /**
674
+ * The theme picker. On a real terminal this is the onboarding screen; anywhere
675
+ * else (`/theme` in a pipe, a test with no TTY) it stays the light/dark toggle
676
+ * it has always been, so a non-interactive caller never blocks on a key.
677
+ */
601
678
  async function showThemePicker() {
602
- commandCtx.light = !commandCtx.light;
603
- commandCtx.themeIndex = commandCtx.light ? 0 : 1;
604
- emit(render.renderNotice(ctx(), 'ok', `theme: ${commandCtx.light ? 'light' : 'dark'}`));
679
+ if (options.readline || !process.stdin.isTTY || !process.stdout.isTTY || options.chatflow === false) {
680
+ commandCtx.light = !commandCtx.light;
681
+ commandCtx.themeIndex = commandCtx.light ? 2 : 1;
682
+ emit(render.renderNotice(ctx(), 'ok', `theme: ${commandCtx.light ? 'light' : 'dark'}`));
683
+ return commandCtx.themeIndex;
684
+ }
685
+ return screens.showThemePicker(commandCtx);
605
686
  }
606
687
 
607
688
  /** Build the FROZEN command context `c` a handler runs against. */
@@ -620,6 +701,10 @@ function createApp(options = {}) {
620
701
  runPrompt: (text) => runPrompt(text),
621
702
  ask: (text) => ask(text),
622
703
  runTool: (name, args) => runTool(name, args),
704
+ // The AEGIS catalog fetch `/model` and alt+p expect (see loadModels).
705
+ // Wired on the app's context so the chatflow's `Object.assign`-based
706
+ // context inherits it too — one definition, both hosts.
707
+ loadModels: (o) => loadModels(o),
623
708
  refreshSpend: () => refreshSpend(),
624
709
  state: () => buildState(),
625
710
  setInput: () => {},
@@ -827,8 +912,43 @@ function createApp(options = {}) {
827
912
  return tokens;
828
913
  }
829
914
 
830
- /** One line of session accounting, for ctrl+t and the meta row. */
831
- function tokenSummary() {
915
+ /**
916
+ * Persist one finished exchange: the history row and a transcript checkpoint.
917
+ *
918
+ * `appendHistory` and `snapshotCheckpoint` were both dead code — nothing ever
919
+ * called them, so `history.jsonl` was never created. Every consumer of it
920
+ * degraded silently rather than loudly: `/resume` could never find a stored
921
+ * session, `/cost` summed zero rows, `/clear`'s prune was a no-op, and
922
+ * `/rewind` always answered "No checkpoints yet". The session loop is the only
923
+ * place that sees the prompt *and* its reply, so it is the place that writes.
924
+ *
925
+ * Best-effort by construction: the two writers swallow their own failures, and
926
+ * accounting must never be able to break a turn.
927
+ */
928
+ function persistTurn(prompt, res, status = 'done') {
929
+ try {
930
+ const usage = res && res.usage;
931
+ appendHistory({
932
+ sessionId: commandCtx.sessionId,
933
+ prompt,
934
+ reply: (res && res.text) || '',
935
+ status,
936
+ usage: usage
937
+ ? {
938
+ input: Number(usage.input_tokens ?? usage.prompt_tokens ?? 0) || 0,
939
+ output: Number(usage.output_tokens ?? usage.completion_tokens ?? 0) || 0,
940
+ cacheRead: Number(usage.cache_read_input_tokens ?? 0) || 0,
941
+ cacheWrite: Number(usage.cache_creation_input_tokens ?? 0) || 0,
942
+ }
943
+ : null,
944
+ });
945
+ snapshotCheckpoint(commandCtx.sessionId, transcript);
946
+ } catch {
947
+ /* persistence is best-effort */
948
+ }
949
+ }
950
+
951
+ /** One line of session accounting, for ctrl+t and the meta row. */ function tokenSummary() {
832
952
  return (
833
953
  `${fmtTokens(session.tokens)} tok ` +
834
954
  `(${fmtTokens(session.inputTokens)} in / ${fmtTokens(session.outputTokens)} out) · ` +
@@ -868,10 +988,13 @@ function createApp(options = {}) {
868
988
  TOOLS,
869
989
  ask: (prompt, o) => ask(prompt, o),
870
990
  makeCommandContext: () => makeCommandContext(),
991
+ loadModels: (o) => loadModels(o),
871
992
  buildState: () => buildState(),
872
993
  dispatchLine: (line, c) => handleLine(line, c),
873
994
  refreshSpend: () => refreshSpend(),
874
995
  updateConfig: (patch) => updateConfig(patch),
996
+ showThemePicker: () => showThemePicker(),
997
+ persistTurn: (prompt, res, status) => persistTurn(prompt, res, status),
875
998
  visibleCommands: () => visibleCommands(),
876
999
  tokensFor: (usage) => usageTokens(usage),
877
1000
  recordTurn: (res) => recordTurn(res),
@@ -926,6 +1049,43 @@ function createApp(options = {}) {
926
1049
  });
927
1050
  }
928
1051
 
1052
+ /**
1053
+ * Restore persisted preferences into the live context. The reference does this
1054
+ * at startup (`main.js:290-296`); this client never called `loadConfig()` on
1055
+ * launch at all, so a model or effort chosen in a previous session was written
1056
+ * to disk and then ignored on the next run.
1057
+ *
1058
+ * An explicit CLI flag always wins over the stored value — `aegiscode -m x`
1059
+ * must mean `x`, not "x unless the config disagrees".
1060
+ */
1061
+ function restorePrefs() {
1062
+ // An embedded/injected readline is a programmatic caller: it gets a
1063
+ // deterministic context rather than whatever happens to be on disk.
1064
+ if (options.readline) return;
1065
+ // A fresh install has no expressed preference to restore. Applying
1066
+ // DEFAULT_CONFIG here would silently pin the user to a model they never
1067
+ // chose, which is the opposite of "remember what I picked".
1068
+ if (!configExists()) return;
1069
+ let cfg;
1070
+ try {
1071
+ cfg = loadConfig();
1072
+ } catch {
1073
+ return;
1074
+ }
1075
+ if (!opts.model && cfg.model) commandCtx.model = cfg.model;
1076
+ if (cfg.effort) commandCtx.effort = cfg.effort;
1077
+ if (typeof cfg.vim === 'boolean') commandCtx.vim = cfg.vim;
1078
+ if (cfg.lastRecap) commandCtx.lastRecap = cfg.lastRecap;
1079
+ // THEME_TABLE lives in theme.js, not screens.js: reading it off the screens
1080
+ // module yielded undefined, and only on a *second* launch (the first has no
1081
+ // config to restore, so this branch never ran) — so the very first run
1082
+ // appeared to work and every run after it died on startup.
1083
+ if (!opts.light && typeof cfg.themeIndex === 'number' && THEME_TABLE[cfg.themeIndex]) {
1084
+ commandCtx.themeIndex = cfg.themeIndex;
1085
+ commandCtx.light = !!THEME_TABLE[cfg.themeIndex].light;
1086
+ }
1087
+ }
1088
+
929
1089
  /**
930
1090
  * Interactive entry point. On a real terminal this is the full chatflow
931
1091
  * (alternate screen, header, transcript viewport, spinner, effort line,
@@ -940,9 +1100,35 @@ function createApp(options = {}) {
940
1100
  process.stdin.isTTY &&
941
1101
  process.stdout.isTTY;
942
1102
  if (tty) {
1103
+ restorePrefs();
1104
+ // Onboarding runs in the normal buffer, before the session takes the
1105
+ // alternate screen — the reference's order. A declined trust check must
1106
+ // abort: the reference returns without ever reaching `session(ctx)`.
1107
+ const onboard = await screens.runOnboarding(commandCtx, {
1108
+ continue: !!options.continue,
1109
+ seen: options.seen || configExists,
1110
+ save: (patch) => updateConfig(patch),
1111
+ });
1112
+ if (!onboard.ok) return 0;
1113
+ // A stored pin the platform does not advertise routes elsewhere in
1114
+ // silence (see validatePinnedModel) — checked once, here, where the user
1115
+ // can act on it. Not on `-p`: a network round-trip ahead of the first
1116
+ // token would be a startup cost bought for a warning no script watches.
1117
+ await validatePinnedModel();
1118
+ // `--continue` must load the last session *before* the loop starts, and
1119
+ // it has to be read here rather than captured at construction: the
1120
+ // history file is written by the loop itself.
1121
+ if (options.continue) {
1122
+ const last = readOwnSessions(1)[0];
1123
+ if (last) {
1124
+ commandCtx.continueSession = last.id;
1125
+ await resumeSession({ id: last.id });
1126
+ }
1127
+ }
943
1128
  await chatflow.runSession(makeHost());
944
1129
  return 0;
945
1130
  }
1131
+ restorePrefs();
946
1132
  return runLinearRepl();
947
1133
  }
948
1134
 
@@ -963,7 +1149,12 @@ function createApp(options = {}) {
963
1149
  refreshSpend,
964
1150
  bannerLines,
965
1151
  makeHost,
1152
+ loadModels,
1153
+ validatePinnedModel,
966
1154
  recordTurn,
1155
+ persistTurn,
1156
+ restorePrefs,
1157
+ sessionId: () => commandCtx.sessionId,
967
1158
  tokenSummary,
968
1159
  resumeSession,
969
1160
  makeCommandContext,
package/src/chatflow.js CHANGED
@@ -207,7 +207,12 @@ function statusLine(state, cols, ctx) {
207
207
  return padLine(left, cols);
208
208
  }
209
209
 
210
- /** The `?` shortcuts grid. */
210
+ /**
211
+ * The `?` shortcuts grid. Every row advertised here is a key this CLI actually
212
+ * handles — the reference sheet also lists chords this host never wired up
213
+ * (shift+tab, `\`+return, `@`, ctrl+z/v/g…), and a cheat-sheet of dead keys is
214
+ * worse than none. Add a row only alongside its handler in `handleKey`.
215
+ */
211
216
  function shortcutsGrid(cols, ctx) {
212
217
  const t = themeOf(ctx);
213
218
  const cell = (a, b, c, d) => [
@@ -215,13 +220,12 @@ function shortcutsGrid(cols, ctx) {
215
220
  span('', ' '), span(t.white, c), span(t.gray, ' ' + d),
216
221
  ];
217
222
  return [
218
- cell('/', 'for commands', 'shift + tab', 'to auto-accept'),
219
- cell('?', 'for shortcuts', 'ctrl + c', 'to quit'),
220
- cell('\\ + return', 'for newline', 'ctrl + o', 'for permissions'),
221
- cell('@', 'for file paths', 'alt + p', 'to switch model'),
223
+ cell('/', 'for commands', '?', 'for shortcuts'),
224
+ cell('ctrl + c', 'to quit', 'ctrl + o', 'for permissions'),
225
+ cell('alt + p', 'to switch model', 'alt + t', 'to toggle thinking'),
222
226
  cell('esc', 'to interrupt a turn', 'ctrl + l', 'to clear the screen'),
223
- [span('', ' '), span(t.white, 'ctrl + t'), span(t.gray, ' to show tokens'), span('', ' '), span(t.white, 'ctrl + r'), span(t.gray, ' to resume a session')],
224
- [span('', ' '), span(t.white, '↑ / ↓'), span(t.gray, ' for history', span(' ', 1)), span('', ' '), span(t.white, 'tab'), span(t.gray, ' to complete a command')],
227
+ cell('ctrl + t', 'to show tokens', 'ctrl + r', 'to resume a session'),
228
+ cell('↑ / ↓', 'for history', 'tab', 'to complete a command'),
225
229
  ];
226
230
  }
227
231
 
@@ -787,6 +791,11 @@ async function runSession(host) {
787
791
  // Accounting: fold the turn's usage into the session tallies, ask the
788
792
  // ledger what it settled at, and show both — tokens beside €.
789
793
  host.recordTurn(result);
794
+ // Persist the finished exchange for /resume. Guarded: the test host stub
795
+ // deliberately omits persistTurn, and the accounting below must still run.
796
+ if (host.persistTurn) {
797
+ host.persistTurn(prompt, result, abortedFlag ? 'stopped' : (result && result.error) ? 'error' : 'done');
798
+ }
790
799
  let lastCost = null;
791
800
  try {
792
801
  const spend = await host.refreshSpend();
@@ -925,10 +934,11 @@ async function runSession(host) {
925
934
  render();
926
935
  },
927
936
  showThemePicker: () => {
928
- ctx.light = !ctx.light;
929
- ctx.themeIndex = ctx.light ? 0 : 1;
930
- host.updateConfig({ themeIndex: ctx.themeIndex });
931
- note(`theme: ${ctx.light ? 'light' : 'dark'}`);
937
+ // The app owns the real picker (the 7-row onboarding screen on a TTY,
938
+ // the light/dark toggle off one). The loop only repaints after it — the
939
+ // old inline toggle hardcoded themeIndex 0/1, which no longer names
940
+ // Light/Dark in theme.js's THEME_TABLE (2 is "Light mode").
941
+ if (host.showThemePicker) host.showThemePicker();
932
942
  render();
933
943
  },
934
944
  });
@@ -1023,6 +1033,23 @@ async function runSession(host) {
1023
1033
  return true;
1024
1034
  };
1025
1035
 
1036
+ // The number of rows the active overlay currently renders, so DOWN can clamp
1037
+ // the highlight to the last row instead of running off the end of the list.
1038
+ const overlayRowCount = () => {
1039
+ if (!overlay) return 0;
1040
+ if (overlay.type === 'palette') {
1041
+ return fuzzy.fuzzyRankWithAliases(
1042
+ paletteQuery(overlay.query),
1043
+ host.visibleCommands(),
1044
+ (c) => c.name,
1045
+ (c) => c.aliases || []
1046
+ ).length;
1047
+ }
1048
+ if (overlay.type === 'model' || overlay.type === 'resume') return (overlay.items || []).length;
1049
+ if (overlay.type === 'effort') return 3;
1050
+ return 0;
1051
+ };
1052
+
1026
1053
  const handleOverlayKey = async (key) => {
1027
1054
  const type = overlay.type;
1028
1055
  if (key.name === KEY.ESC || key.name === KEY.CTRL_C) {
@@ -1035,6 +1062,24 @@ async function runSession(host) {
1035
1062
  render();
1036
1063
  return;
1037
1064
  }
1065
+ if (key.name === KEY.TAB && type === 'palette') {
1066
+ // Tab completes the query to the highlighted command, ranking with the
1067
+ // same function the palette rendered with so the row completed is the
1068
+ // row Enter would run. Nothing highlighted (no matches) → do nothing.
1069
+ const list = fuzzy.fuzzyRankWithAliases(
1070
+ paletteQuery(overlay.query),
1071
+ host.visibleCommands(),
1072
+ (c) => c.name,
1073
+ (c) => c.aliases || []
1074
+ );
1075
+ const chosen = list[overlay.sel || 0];
1076
+ if (chosen) {
1077
+ overlay.query = chosen.name;
1078
+ overlay.sel = 0;
1079
+ render();
1080
+ }
1081
+ return;
1082
+ }
1038
1083
  if (key.name === KEY.ENTER) {
1039
1084
  if (type === 'palette') {
1040
1085
  // Rank with the same function the palette rendered with, so the row
@@ -1092,7 +1137,8 @@ async function runSession(host) {
1092
1137
  return;
1093
1138
  }
1094
1139
  if (key.name === KEY.DOWN) {
1095
- overlay.sel = (overlay.sel || 0) + 1;
1140
+ const max = Math.max(0, overlayRowCount() - 1);
1141
+ overlay.sel = Math.min(max, (overlay.sel || 0) + 1);
1096
1142
  render();
1097
1143
  return;
1098
1144
  }
@@ -1132,6 +1178,22 @@ async function runSession(host) {
1132
1178
  await handleOverlayKey(key);
1133
1179
  return;
1134
1180
  }
1181
+ // A bare '/' opens the palette and a bare '?' the shortcuts grid — the
1182
+ // reference opens both on the keystroke (main.js:1542-1543), not after
1183
+ // Enter. With text already in the line both insert literally, so '/model'
1184
+ // and 'why?' still type.
1185
+ if (key.name === 'char' && !editor.buf) {
1186
+ if (key.ch === '/') {
1187
+ overlay = { type: 'palette', query: '', sel: 0 };
1188
+ render();
1189
+ return;
1190
+ }
1191
+ if (key.ch === '?') {
1192
+ overlay = { type: 'shortcuts' };
1193
+ render();
1194
+ return;
1195
+ }
1196
+ }
1135
1197
  // vim normal-mode motions (only when the buffer is empty, so j/k do not
1136
1198
  // fight typed text).
1137
1199
  if (ctx.vim && !insertMode && !editor.buf && key.name === 'char') {
@@ -1258,7 +1320,17 @@ async function runSession(host) {
1258
1320
  return;
1259
1321
  }
1260
1322
  if (key.name === KEY.TAB) {
1261
- completeTab();
1323
+ // completeTab cycles command names; when it finds nothing, flash the
1324
+ // reference's hint in the idle line — the alt+t nudge on an empty buffer,
1325
+ // a "Tab completes" reminder once text is typed (main.js:1598-1606).
1326
+ // hintUntil/hintText were declared and rendered but never set.
1327
+ if (!completeTab()) {
1328
+ hintText = editor.buf ? 'Tab completes commands' : 'Use alt+t to toggle thinking';
1329
+ hintUntil = Date.now() + 2500;
1330
+ setTimeout(() => {
1331
+ if (Date.now() >= hintUntil) render();
1332
+ }, 2600);
1333
+ }
1262
1334
  render();
1263
1335
  return;
1264
1336
  }
@@ -1274,7 +1346,9 @@ async function runSession(host) {
1274
1346
  return;
1275
1347
  }
1276
1348
  if (key.name === KEY.CTRL_W) {
1277
- editor.wordDelete();
1349
+ // Word-rubout: step the cursor back a word, no deletion — the reference's
1350
+ // Ctrl+W (main.js:1624). LineEditor.wordBack is the method that exists.
1351
+ editor.wordBack();
1278
1352
  render();
1279
1353
  return;
1280
1354
  }
@@ -1292,6 +1366,12 @@ async function runSession(host) {
1292
1366
  await dispatch('/resume');
1293
1367
  return;
1294
1368
  }
1369
+ if (key.name === KEY.CTRL_O) {
1370
+ // Keybinding parity: Ctrl+O opens the permissions panel (main.js:1644),
1371
+ // which /permissions already renders in this CLI.
1372
+ await dispatch('/permissions');
1373
+ return;
1374
+ }
1295
1375
  if (key.name === KEY.PAGE_UP || key.name === KEY.PAGE_DOWN || key.name === 'wheel') {
1296
1376
  applyLiveScroll(key);
1297
1377
  return;
@@ -1300,6 +1380,27 @@ async function runSession(host) {
1300
1380
  await dispatch('/model');
1301
1381
  return;
1302
1382
  }
1383
+ if (key.name === 'alt' && key.ch === 't') {
1384
+ // Toggle extended thinking, using the same shape/value as /thinking so
1385
+ // the chord and the command cannot disagree (commands.js: thinking).
1386
+ const want = !(ctx.thinking === true);
1387
+ ctx.thinking = want;
1388
+ host.updateConfig({ thinking: want });
1389
+ note(`Thinking blocks: ${want ? 'expanded' : 'collapsed'}`);
1390
+ render();
1391
+ return;
1392
+ }
1393
+ if (key.name === KEY.ESC) {
1394
+ // A bare Esc on an idle line clears it (readline's rule; the reference
1395
+ // clears the buffer, main.js:1675). The vim insert-mode Esc above is a
1396
+ // distinct motion and must not be shadowed by this.
1397
+ if (editor.buf) {
1398
+ editor.buf = '';
1399
+ editor.cursor = 0;
1400
+ render();
1401
+ }
1402
+ return;
1403
+ }
1303
1404
  if (key.name === KEY.CTRL_C) {
1304
1405
  if (editor.buf) {
1305
1406
  editor.buf = '';
@@ -1311,12 +1412,11 @@ async function runSession(host) {
1311
1412
  return;
1312
1413
  }
1313
1414
  if (key.name === KEY.CTRL_D) {
1314
- if (!editor.buf) {
1315
- await endSession();
1316
- return;
1317
- }
1318
- editor.delete();
1319
- render();
1415
+ // Ctrl+D is EOF: it ends the session only on an empty line. With text in
1416
+ // the buffer the reference never deletes-forward (main.js:1673), so a
1417
+ // stray Ctrl+D must neither drop a character nor kill the session.
1418
+ if (!editor.buf) await endSession();
1419
+ return;
1320
1420
  }
1321
1421
  };
1322
1422