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 +47 -4
- package/bin/aegiscode.js +13 -1
- package/package.json +1 -1
- package/src/app.js +201 -10
- package/src/chatflow.js +120 -20
- package/src/commands.js +99 -13
- package/src/config.js +11 -1
- package/src/events.js +32 -0
- package/src/models.js +123 -0
- package/src/render.js +4 -0
- package/src/screens.js +463 -0
- package/src/theme.js +68 -2
- package/vendor/desktop/lib/local/engine.js +11 -0
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.
|
|
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, `
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
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.
|
|
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 {
|
|
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 ?
|
|
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
|
-
|
|
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
|
-
|
|
603
|
-
|
|
604
|
-
|
|
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
|
-
/**
|
|
831
|
-
|
|
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
|
-
/**
|
|
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', '
|
|
219
|
-
cell('
|
|
220
|
-
cell('
|
|
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
|
-
|
|
224
|
-
|
|
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
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
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
|
|