jaitus 0.3.0

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.
Files changed (55) hide show
  1. package/LICENSE +32 -0
  2. package/README.md +248 -0
  3. package/bin/jaitus.js +60 -0
  4. package/package.json +46 -0
  5. package/src/agent/loop.js +548 -0
  6. package/src/agent/permissions.js +218 -0
  7. package/src/agent/rules.js +190 -0
  8. package/src/agent/tools.js +923 -0
  9. package/src/agent/websearch.js +522 -0
  10. package/src/api.js +72 -0
  11. package/src/background.js +85 -0
  12. package/src/brand.js +232 -0
  13. package/src/commands/code-review.js +8 -0
  14. package/src/commands/commit.js +73 -0
  15. package/src/commands/completion.js +68 -0
  16. package/src/commands/doctor.js +185 -0
  17. package/src/commands/explain.js +33 -0
  18. package/src/commands/init.js +62 -0
  19. package/src/commands/login.js +201 -0
  20. package/src/commands/logout.js +25 -0
  21. package/src/commands/research.js +60 -0
  22. package/src/commands/review.js +80 -0
  23. package/src/commands/security-review.js +8 -0
  24. package/src/commands/simplify.js +8 -0
  25. package/src/commands/test.js +68 -0
  26. package/src/commands/update.js +97 -0
  27. package/src/config.js +95 -0
  28. package/src/diff.js +129 -0
  29. package/src/errors.js +101 -0
  30. package/src/hooks.js +55 -0
  31. package/src/index.js +2503 -0
  32. package/src/ledger.js +73 -0
  33. package/src/logo-data.js +33 -0
  34. package/src/mcp.js +160 -0
  35. package/src/open.js +50 -0
  36. package/src/project.js +0 -0
  37. package/src/rewind.js +130 -0
  38. package/src/session.js +207 -0
  39. package/src/sessions.js +179 -0
  40. package/src/sixel.js +131 -0
  41. package/src/skills.js +62 -0
  42. package/src/term-sim.js +103 -0
  43. package/src/terminal.js +90 -0
  44. package/src/tui/ansi.js +287 -0
  45. package/src/tui/entries.js +366 -0
  46. package/src/tui/highlight.js +211 -0
  47. package/src/tui/logo.js +93 -0
  48. package/src/tui/markdown.js +280 -0
  49. package/src/tui/mouse.js +125 -0
  50. package/src/tui/theme.js +201 -0
  51. package/src/tui/widgets.js +226 -0
  52. package/src/tui/wordmark.js +135 -0
  53. package/src/tui-ink.js +1927 -0
  54. package/src/tui.js +2555 -0
  55. package/src/ui.js +562 -0
package/src/index.js ADDED
@@ -0,0 +1,2503 @@
1
+ // ─── jaitus ─────────────────────────────────────────────────────────────────────
2
+ //
3
+ // `jaitus` with a prompt runs one task and exits. `jaitus` on its own opens a session.
4
+ // Both drive the same agent loop; the only difference is whether the conversation
5
+ // survives the turn. Everything else here is the shape of a command-line tool: one
6
+ // registry of commands that feeds help, completion and dispatch alike, so a command
7
+ // cannot exist without being documented or completable.
8
+
9
+ import path from 'node:path';
10
+ import fs from 'node:fs';
11
+ import { spawn, spawnSync } from 'node:child_process';
12
+ import { createClient, NotSignedIn } from './api.js';
13
+ import { fromResponse, isJaitusError } from './errors.js';
14
+ import { signedIn } from './session.js';
15
+ import {
16
+ loadCredentials, clearCredentials, loadSettings, saveSettings,
17
+ endpoints, version, HOME, HISTORY,
18
+ } from './config.js';
19
+ import { runAgent } from './agent/loop.js';
20
+ import { createPermissions } from './agent/permissions.js';
21
+ import { forgetIgnoreCache, imageBlock } from './agent/tools.js';
22
+ import { loadContext, CONTEXT_FILE } from './project.js';
23
+ import { discoverSkills, skillCatalog } from './skills.js';
24
+ import { runHook, configuredHookEvents } from './hooks.js';
25
+ import { newSession, saveSession, loadSession, latestSession, listSessions, sessionSummary, replaySession } from './sessions.js';
26
+ import { createJournal, displayPath } from './rewind.js';
27
+ import { createLedger } from './ledger.js';
28
+ import { listMcpServers, addMcpServer, removeMcpServer } from './mcp.js';
29
+ import { banner, wordmark, renderHomeLogo, startupTip } from './brand.js';
30
+ import {
31
+ c, out, err, ok, fail, info, warn, table, present,
32
+ promptLine, useHistoryFile, watchEsc, setTuiSink, askKey, colorLevel,
33
+ unicode, glyph, columns,
34
+ } from './ui.js';
35
+ import { createTui as createClassicTui } from './tui.js';
36
+ import { createTui as createInkTui } from './tui-ink.js';
37
+
38
+ // The interactive surface is Node-only. The classic renderer remains an
39
+ // explicit escape hatch for terminals that cannot support fullscreen Ink.
40
+ const createTui = process.env.JAITUS_RENDERER === 'classic' ? createClassicTui : createInkTui;
41
+ import login from './commands/login.js';
42
+ import logout from './commands/logout.js';
43
+ import doctor from './commands/doctor.js';
44
+ import update from './commands/update.js';
45
+ import init from './commands/init.js';
46
+ import research from './commands/research.js';
47
+ import explain from './commands/explain.js';
48
+ import testCmd from './commands/test.js';
49
+ import commitCmd from './commands/commit.js';
50
+ import codeReview from './commands/code-review.js';
51
+ import securityReview from './commands/security-review.js';
52
+ import simplify from './commands/simplify.js';
53
+ import completion, { SHELLS } from './commands/completion.js';
54
+
55
+ const VERSION = version();
56
+
57
+ // ─── Options ────────────────────────────────────────────────────────────────────
58
+
59
+ const OPTIONS = [
60
+ ['--print, -p', 'run one prompt non-interactively and exit'],
61
+ ['--continue, -c', 'continue the most recent session in this directory'],
62
+ ['--resume, -r <id>', 'resume a session by id'],
63
+ ['--output-format <format>', 'print, json or stream-json in print mode'],
64
+ ['--max-turns <n>', 'limit agent turns in non-interactive mode'],
65
+ ['--permission-mode <mode>', 'default, acceptEdits, plan or bypassPermissions'],
66
+ ['--dangerously-skip-permissions', 'alias for bypassPermissions; trusted workspaces only'],
67
+ ['--system-prompt <text>', 'replace the model system prompt in print mode'],
68
+ ['--append-system-prompt <text>', 'add instructions to the system prompt'],
69
+ ['--model <id>', 'use this model for this run'],
70
+ ['--cwd <dir>', 'work in another directory'],
71
+ ['--yes, -y', 'do not ask before writing files or running commands'],
72
+ ['--json', 'machine-readable output, where a command reports something'],
73
+ ['--no-color', 'plain text, no escape codes'],
74
+ ['--no-banner', 'skip the logo'],
75
+ ['--help, -h', 'this help, or help for a command'],
76
+ ['--version, -v', 'print the version'],
77
+ ];
78
+
79
+ const FLAG_NAMES = ['--print', '-p', '--continue', '-c', '--resume', '-r', '--output-format', '--max-turns', '--permission-mode', '--dangerously-skip-permissions', '--system-prompt', '--append-system-prompt', '--model', '--cwd', '--yes', '-y', '--json', '--no-color', '--no-banner', '--help', '-h', '--version', '-v', '--force', '--ascii'];
80
+
81
+ function parseArgs(argv) {
82
+ const flags = {
83
+ model: null, yes: false, cwd: process.cwd(), json: false, help: false, version: false,
84
+ print: false, continue: false, resume: null, outputFormat: 'text', maxTurns: 40,
85
+ permissionMode: 'default', systemPrompt: '', appendSystemPrompt: '',
86
+ };
87
+ const rest = [];
88
+ const passthrough = [];
89
+ let literal = false;
90
+
91
+ for (let i = 0; i < argv.length; i++) {
92
+ const a = argv[i];
93
+ if (literal) { rest.push(a); continue; }
94
+
95
+ if (a === '--') { literal = true; continue; }
96
+ else if (a === '--print' || a === '-p') flags.print = true;
97
+ else if (a === '--continue' || a === '-c') flags.continue = true;
98
+ else if (a === '--resume' || a === '-r') flags.resume = argv[++i];
99
+ else if (a === '--output-format') flags.outputFormat = argv[++i] || 'text';
100
+ else if (a === '--max-turns') flags.maxTurns = Math.max(1, Number(argv[++i]) || 40);
101
+ else if (a === '--permission-mode') flags.permissionMode = argv[++i] || 'default';
102
+ else if (a === '--dangerously-skip-permissions') flags.permissionMode = 'bypassPermissions';
103
+ else if (a === '--system-prompt') flags.systemPrompt = argv[++i] || '';
104
+ else if (a === '--append-system-prompt') flags.appendSystemPrompt = argv[++i] || '';
105
+ else if (a === '--model') flags.model = argv[++i];
106
+ else if (a === '--cwd') flags.cwd = path.resolve(argv[++i] || '.');
107
+ else if (a === '--yes' || a === '-y') flags.yes = true;
108
+ else if (a === '--json') flags.json = true;
109
+ else if (a === '--help' || a === '-h') flags.help = true;
110
+ else if (a === '--version' || a === '-v') flags.version = true;
111
+ // Applied to the environment rather than carried around, because ui.js reads these
112
+ // per call and a dozen call sites should not each have to be told.
113
+ else if (a === '--no-color') process.env.JAITUS_NO_COLOR = '1';
114
+ else if (a === '--no-banner') process.env.JAITUS_NO_BANNER = '1';
115
+ else if (a === '--ascii') process.env.JAITUS_ASCII = '1';
116
+ else if (a.startsWith('-') && a.length > 1) passthrough.push(a);
117
+ else rest.push(a);
118
+ }
119
+ return { flags, rest, passthrough };
120
+ }
121
+
122
+ // ─── Help ───────────────────────────────────────────────────────────────────────
123
+
124
+ function helpText() {
125
+ const lines = [];
126
+ lines.push(` ${c.bold('USAGE')}`);
127
+ lines.push(` jaitus start a session in this directory`);
128
+ lines.push(` jaitus "<task>" run one task and exit`);
129
+ lines.push(` cat error.log | jaitus "why?" pipe something in as context`);
130
+ lines.push('');
131
+ lines.push(` ${c.bold('COMMANDS')}`);
132
+ for (const [name, cmd] of Object.entries(commands)) {
133
+ if (cmd.hidden) continue;
134
+ lines.push(` ${name.padEnd(32)}${c.dim(cmd.summary)}`);
135
+ }
136
+ lines.push('');
137
+ lines.push(` ${c.bold('OPTIONS')}`);
138
+ for (const [flag, description] of OPTIONS) {
139
+ lines.push(` ${flag.padEnd(32)}${c.dim(description)}`);
140
+ }
141
+ lines.push('');
142
+ lines.push(` ${c.bold('IN A SESSION')}`);
143
+ for (const [name, s] of Object.entries(SLASH)) {
144
+ if (s.hidden) continue;
145
+ lines.push(` ${name.padEnd(32)}${c.dim(s.summary)}`);
146
+ }
147
+ lines.push('');
148
+ lines.push(` ${c.dim(`jaitus help <command> for detail on one command`)}`);
149
+ lines.push('');
150
+ return lines.join('\n');
151
+ }
152
+
153
+ function commandHelp(name) {
154
+ const cmd = commands[name];
155
+ if (!cmd) {
156
+ fail(`No such command: ${name}`);
157
+ info(`Run ${c.bold('jaitus --help')} to see them all.`);
158
+ return 1;
159
+ }
160
+ out('');
161
+ out(` ${c.bold(`jaitus ${name}`)} ${c.dim('— ' + cmd.summary)}`);
162
+ if (cmd.usage) {
163
+ out('');
164
+ for (const line of cmd.usage) out(` ${line}`);
165
+ }
166
+ if (cmd.detail) {
167
+ out('');
168
+ for (const line of cmd.detail) out(` ${line}`);
169
+ }
170
+ out('');
171
+ return 0;
172
+ }
173
+
174
+ // ─── Shared plumbing ────────────────────────────────────────────────────────────
175
+
176
+ /** Anything piped in becomes context for the task. */
177
+ async function readStdin() {
178
+ if (process.stdin.isTTY) return '';
179
+ const chunks = [];
180
+ for await (const chunk of process.stdin) chunks.push(chunk);
181
+ return Buffer.concat(chunks).toString('utf8').trim();
182
+ }
183
+
184
+ class NoModel extends Error {}
185
+
186
+ async function resolveModel(client, preferred) {
187
+ if (preferred) return preferred;
188
+ const saved = loadSettings().model;
189
+ if (saved) return saved;
190
+ const available = await client.models();
191
+ // The gateway maps whatever it advertises onto the model that actually serves it, so
192
+ // the first advertised id is always a valid choice. There is deliberately no hardcoded
193
+ // fallback: an id baked in here would be a second, stale source of truth, and sending
194
+ // one the gateway does not list produces a confusing rejection instead of this.
195
+ if (!available.length) {
196
+ throw new NoModel(
197
+ 'The gateway did not advertise any models, so there is nothing to run against. '
198
+ + 'Run `jaitus doctor` to see where it stopped, or `jaitus --model <id>` if you know one.'
199
+ );
200
+ }
201
+ return available[0];
202
+ }
203
+
204
+ /** Rough token estimate for /context and /status — ~4 characters per token of English. */
205
+ function estimateTokens(messages) {
206
+ let chars = 0;
207
+ for (const m of messages) {
208
+ if (typeof m.content === 'string') chars += m.content.length;
209
+ else if (Array.isArray(m.content)) {
210
+ for (const b of m.content) {
211
+ if (b.type === 'text') chars += (b.text || '').length;
212
+ else if (b.type === 'tool_use') chars += JSON.stringify(b.input || {}).length;
213
+ else if (b.type === 'tool_result') chars += (b.content || '').length;
214
+ }
215
+ }
216
+ }
217
+ return Math.ceil(chars / 4);
218
+ }
219
+
220
+ /** Clipboard write that degrades honestly: returns false when no path exists. */
221
+ async function copyToClipboard(text) {
222
+ const { spawn } = await import('node:child_process');
223
+ const { promisify } = await import('node:util');
224
+ const platforms = process.platform === 'win32' ? [['clip']]
225
+ : process.platform === 'darwin' ? [['pbcopy']]
226
+ : [['wl-copy'], ['xclip', ['-selection', 'clipboard']], ['xsel', ['--clipboard', '--input']]];
227
+ for (const [cmd, args = []] of platforms) {
228
+ try {
229
+ const child = spawn(cmd, args, { stdio: ['pipe', 'ignore', 'ignore'] });
230
+ const done = promisify((cb) => {
231
+ child.on('close', (code) => cb(null, code === 0));
232
+ child.on('error', () => cb(null, false));
233
+ });
234
+ child.stdin.write(text);
235
+ child.stdin.end();
236
+ if (await done()) return true;
237
+ } catch { /* next candidate */ }
238
+ }
239
+ return false;
240
+ }
241
+
242
+ function requireAuth() {
243
+ try {
244
+ return createClient();
245
+ } catch (e) {
246
+ if (e instanceof NotSignedIn) {
247
+ fail(e.message);
248
+ info(`Run ${c.bold('jaitus login')} — it opens your browser, no key to copy.`);
249
+ process.exit(1);
250
+ }
251
+ throw e;
252
+ }
253
+ }
254
+
255
+ /**
256
+ * The one place a failure becomes something the user can see and act on.
257
+ *
258
+ * Inside the app view this hands the error object — status, message, stack and all —
259
+ * to the transcript, which renders it as a red-barred block with a hint and a folded
260
+ * stack. Outside it, the same facts go to stderr where a pipeline can read them.
261
+ *
262
+ * `activeTui` is module-level rather than threaded through because the process-level
263
+ * handlers installed by `session()` have no other way to reach the renderer, and an
264
+ * uncaught error in the alternate screen is otherwise written to a stderr that nothing
265
+ * is showing — which is precisely how a hard crash came to look like a hung session.
266
+ */
267
+ let activeTui = null;
268
+
269
+ function reportAgentError(e, context = {}) {
270
+ const error = e instanceof Error ? e : new Error(String(e?.message ?? e ?? 'Unknown error'));
271
+ if (activeTui?.error) {
272
+ activeTui.error(error, context);
273
+ return 1;
274
+ }
275
+ // Only a JaitusError carries text we wrote for the user. Anything else is a fault in
276
+ // this CLI, and its message — a TypeError, a path — is for debugging, not the user.
277
+ if (isJaitusError(error)) {
278
+ fail(error.message);
279
+ if (error.hint) info(error.hint);
280
+ if (error.code === 'free_limit_reached' || error.code === 'free_request_too_large') info('Subscribe at ' + endpoints().siteUrl + '/billing');
281
+ if (error.requestId) info(c.dim(`request id ${error.requestId}`));
282
+ } else if (error instanceof NotSignedIn) {
283
+ fail(error.message);
284
+ } else {
285
+ fail('Something went wrong in Jaitus.');
286
+ info('Try again; run with JAITUS_DEBUG=1 to see the details.');
287
+ if (process.env.JAITUS_DEBUG && error.stack) err(c.dim(error.stack));
288
+ }
289
+ return 1;
290
+ }
291
+
292
+ /** The current branch, for the header. Absent outside a repo, which is fine. */
293
+ function gitBranch(cwd) {
294
+ try {
295
+ const r = spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd, encoding: 'utf8', timeout: 1500 });
296
+ const name = r.status === 0 ? String(r.stdout || '').trim() : '';
297
+ return name && name !== 'HEAD' ? name : '';
298
+ } catch {
299
+ return '';
300
+ }
301
+ }
302
+
303
+ // Used only to fill the context meter. The real ceiling is the model's, which the
304
+ // gateway does not advertise; this is the conservative floor of what any model it
305
+ // serves accepts, so the bar reads "getting full" before anything actually breaks.
306
+ const CONTEXT_BUDGET = 180_000;
307
+
308
+ // Compact at this fraction of the budget. Low enough that the summarisation request
309
+ // itself still fits comfortably, high enough that a normal session never sees it.
310
+ const AUTO_COMPACT_AT = 0.78;
311
+
312
+ // An expanded @mention is capped: pulling a 4 MB lockfile into the prompt because
313
+ // someone typed `@package-lock.json` would blow the context it is meant to save.
314
+ const MENTION_MAX_BYTES = 48_000;
315
+
316
+ /**
317
+ * One summarisation call. Shared by /compact and auto-compaction so the two cannot
318
+ * drift into producing different kinds of summary from the same conversation.
319
+ */
320
+ async function summarise(client, model, messages, focus) {
321
+ const res = await client.messages({
322
+ model,
323
+ max_tokens: 2048,
324
+ messages: [
325
+ ...messages,
326
+ {
327
+ role: 'user',
328
+ content: 'Summarize this conversation compactly for a successor agent: what was asked, '
329
+ + 'what was done (files changed, commands run, outcomes), what remains. Be specific and terse.'
330
+ + (focus ? ` Focus on: ${focus}.` : ''),
331
+ },
332
+ ],
333
+ stream: false,
334
+ });
335
+ if (!res.ok) throw await fromResponse(res);
336
+ const body = await res.json();
337
+ const text = (body.content || []).filter((b) => b.type === 'text').map((b) => b.text).join('\n');
338
+ if (!text) throw new Error('the summary came back empty');
339
+ return text;
340
+ }
341
+
342
+ /**
343
+ * `@path` in a message → the file's contents as a content block.
344
+ *
345
+ * Directories list instead of reading, which is what someone typing `@src/` means.
346
+ * Anything unreadable is reported rather than skipped: silently dropping a mention
347
+ * would let the agent answer confidently about a file it never saw.
348
+ */
349
+ function expandMentions(input, cwd) {
350
+ const blocks = [];
351
+ const notes = [];
352
+ const seen = new Set();
353
+ for (const match of String(input).matchAll(/(?:^|\s)@([^\s@]+)/g)) {
354
+ const ref = match[1].replace(/[.,;:)]+$/, '');
355
+ if (!ref || seen.has(ref)) continue;
356
+ seen.add(ref);
357
+ const full = path.resolve(cwd, ref);
358
+ if (!full.startsWith(path.resolve(cwd))) { notes.push(`@${ref} is outside the working directory — not attached.`); continue; }
359
+ try {
360
+ const stat = fs.statSync(full);
361
+ if (stat.isDirectory()) {
362
+ const entries = fs.readdirSync(full, { withFileTypes: true })
363
+ .filter((e) => !e.name.startsWith('.'))
364
+ .slice(0, 200)
365
+ .map((e) => (e.isDirectory() ? `${e.name}/` : e.name));
366
+ blocks.push({ type: 'text', text: `<mentioned path="${ref}" kind="directory">\n${entries.join('\n')}\n</mentioned>` });
367
+ notes.push(`@${ref} — ${entries.length} entries attached`);
368
+ continue;
369
+ }
370
+ if (stat.size > MENTION_MAX_BYTES) {
371
+ notes.push(`@${ref} is ${Math.round(stat.size / 1024)} kB — too large to attach; the agent can read it if it needs to.`);
372
+ continue;
373
+ }
374
+ const text = fs.readFileSync(full, 'utf8');
375
+ blocks.push({ type: 'text', text: `<mentioned path="${ref}">\n${text}\n</mentioned>` });
376
+ notes.push(`@${ref} — ${text.split('\n').length} lines attached`);
377
+ } catch {
378
+ notes.push(`@${ref} could not be read — not attached.`);
379
+ }
380
+ }
381
+ return { blocks, notes };
382
+ }
383
+
384
+ /** Saved allow/deny rules for one project directory. */
385
+ function projectRules(cwd) {
386
+ const saved = loadSettings().permissionRules?.[cwd] || {};
387
+ return { allow: Array.isArray(saved.allow) ? saved.allow : [], deny: Array.isArray(saved.deny) ? saved.deny : [] };
388
+ }
389
+
390
+ /** 18400 → "18.4k". Header space is scarce and the exact digit never mattered. */
391
+ const format = (n) => (n >= 1000 ? `${(n / 1000).toFixed(n >= 10000 ? 0 : 1)}k` : String(n));
392
+
393
+ async function oneShot(task, { flags }) {
394
+ const client = requireAuth();
395
+ const model = await resolveModel(client, flags.model);
396
+ const messages = [{ role: 'user', content: task }];
397
+ const permissions = createPermissions({ autoApprove: flags.yes, mode: flags.permissionMode });
398
+ const format = ['text', 'json', 'stream-json'].includes(flags.outputFormat) ? flags.outputFormat : 'text';
399
+ let answer = '';
400
+
401
+ try {
402
+ const result = await runAgent({
403
+ client, messages, model, cwd: flags.cwd, permissions,
404
+ maxIterations: flags.maxTurns,
405
+ silent: true,
406
+ systemOverride: flags.systemPrompt || null,
407
+ extraSystem: flags.appendSystemPrompt ? `\n\nAdditional instructions:\n${flags.appendSystemPrompt}` : '',
408
+ onText: (text) => {
409
+ answer += text;
410
+ if (format === 'stream-json') out(JSON.stringify({ type: 'content_block_delta', delta: { text } }));
411
+ },
412
+ });
413
+ if (format === 'json') {
414
+ out(JSON.stringify({ type: 'result', result: answer, model, stop_reason: result.stopReason, usage: result }));
415
+ } else if (format === 'stream-json') {
416
+ out(JSON.stringify({ type: 'message_stop', stop_reason: result.stopReason, usage: result }));
417
+ } else {
418
+ out(answer);
419
+ }
420
+ return 0;
421
+ } catch (e) {
422
+ if (format !== 'text') out(JSON.stringify({ type: 'error', error: e.message, status: e.status || 500 }));
423
+ if (format !== 'text') return 1;
424
+ return reportAgentError(e);
425
+ }
426
+ }
427
+
428
+ // ─── The session ────────────────────────────────────────────────────────────────
429
+
430
+ const SLASH = {
431
+ '/help': { summary: 'these commands' },
432
+ '/model [id]': { summary: 'show or change the model for this session' },
433
+ '/models': { summary: 'model ids Jaitus accepts', hidden: true }, // duplicate of /model, which lists them and lets you pick
434
+ '/status': { summary: 'model, mode, account, directory, context usage' },
435
+ '/cost': { summary: 'tokens used so far this session' },
436
+ '/context': { summary: 'what the model sees this turn (messages, project notes)' },
437
+ '/cwd': { summary: 'which directory the agent is working in', hidden: true }, // already in the header and in /status
438
+ '/clear': { summary: 'start the conversation over' },
439
+ '/undo': { summary: 'drop the last exchange' },
440
+ '/compact [focus]': { summary: 'summarize the conversation to free context' },
441
+ '/export [file]': { summary: 'write the conversation to a file (txt/md/json)' },
442
+ '/copy': { summary: 'copy the last response to the clipboard' },
443
+ '/diff': { summary: 'what changed on disk since the session started' },
444
+ '/tasks': { summary: 'review tool activity from this session' },
445
+ '/image <path>': { summary: 'attach an image (png/jpg/gif/webp) the model will see with your next message' },
446
+ '/memory [text]': { summary: 'show or append to JAITUS.md (# does this inline)' },
447
+ '/permissions [mode|rules]': { summary: 'permission mode, and the allow/deny rules for this project' },
448
+ '/skills': { summary: 'list project workflows' },
449
+ '/sessions': { summary: 'list saved sessions in this directory' },
450
+ '/session': { summary: 'show this session id', hidden: true }, // the id is already in /status
451
+ '/resume <id>': { summary: 'load another saved session' },
452
+ '/rename <title>': { summary: 'name this session' },
453
+ '/hooks': { summary: 'show configured lifecycle hooks' },
454
+ '/tui [fullscreen|default]': { summary: 'switch renderer, relaunching with this conversation intact' },
455
+ '/fork': { summary: 'copy this conversation into a brand-new session id', hidden: true }, // duplicate of /branch
456
+ '/theme [name]': { summary: 'show or set the color theme (dark, light, daltonized, ocean, plain, auto)' },
457
+ '/scroll-speed [n]': { summary: 'show or set mouse-wheel scroll speed (0.25-10)', hidden: true }, // belongs in /config
458
+ '/vim': { summary: 'toggle Vim modal editing for the prompt' },
459
+ '/attach <id>': { summary: 'watch a background job log live (Esc stops watching)' },
460
+ '/stop <id>': { summary: 'stop a running background job' },
461
+ '/debug': { summary: 'terminal + renderer diagnostics for bug reports' },
462
+ '/agent <task>': { summary: 'run an isolated read-only side task' },
463
+ '/btw <question>': { summary: 'side question, answered without touching history' },
464
+ '/research [--deep] <q>': { summary: 'agentic web research with sources (read-only)' },
465
+ '/deep-research <question>': { summary: 'deeper web research, more rounds, cited report', hidden: true }, // use /research --deep
466
+ '/ask <question>': { summary: 'read-only explanation mode; never edits files' },
467
+ '/architect <task>': { summary: 'read-only architecture mode; compare designs and risks' },
468
+ '/code': { summary: 'implementation mode; keep the current approval policy' },
469
+ '/explain <topic>': { summary: 'explain a file, symbol, error or concept from this project' },
470
+ '/test [args]': { summary: 'run the project tests; --fix lets the agent repair failures' },
471
+ '/code-review [--fix]': { summary: 'review the diff for bugs; --fix applies findings', hidden: true }, // same command as /review
472
+ '/review [--fix]': { summary: 'review the diff for bugs; --fix applies the findings' },
473
+ '/security-review [--fix]': { summary: 'review the diff for vulnerabilities' },
474
+ '/simplify [--fix]': { summary: 'readability-only pass over the change' },
475
+ '/commit': { summary: 'review the diff, write a real message, commit (push is separate)' },
476
+ '/plan [task]': { summary: 'read-only plan mode, optionally for one task' },
477
+ '/branch [name]': { summary: 'branch the conversation, return with /resume' },
478
+ '/rewind [turn]': { summary: 'restore conversation and files to an earlier turn' },
479
+ '/feedback [text]': { summary: 'send feedback with session context' },
480
+ '/version': { summary: 'print the running version', hidden: true }, // already in /status
481
+ '/animations [on|off]': { summary: 'motion: shimmer, sweeps, stagger (or stillness)', hidden: true }, // belongs in /config
482
+ '/login': { summary: 'sign in as a different account' },
483
+ '/logout': { summary: 'sign this device out and leave' },
484
+ '/doctor': { summary: 'diagnose setup problems' },
485
+ '/whoami': { summary: 'who you are signed in as' },
486
+ '/usage': { summary: 'your usage and remaining free prompts' },
487
+ '/init': { summary: 'create JAITUS.md and .jaitusignore here' },
488
+ '/mcp [list|add|remove]': { summary: 'list or configure MCP tool servers' },
489
+ '/config [key] [value]': { summary: 'show or change CLI settings' },
490
+ '/update': { summary: 'check whether a newer version is published' },
491
+ '/banner': { summary: 'draw the Jaitus mark', hidden: true }, // draws a logo into a session that already shows one
492
+ '/exit': { summary: 'leave (the only way out — Ctrl+C never exits)' },
493
+ };
494
+
495
+ // Hidden commands still WORK — nothing has been taken away from anyone's fingers. They
496
+ // are kept out of the menu, the palette, completion and /help because each one either
497
+ // duplicates a listed command or is a setting that belongs in /config, and a list of
498
+ // sixty where twenty would do is a list nobody reads.
499
+ const SLASH_NAMES = Object.keys(SLASH).filter((key) => !SLASH[key].hidden).map((s) => s.split(' ')[0]);
500
+ const SLASH_ALL = Object.keys(SLASH).map((s) => s.split(' ')[0]);
501
+
502
+ /**
503
+ * Best-effort UTF-8 upgrade for classic conhost: read the active codepage,
504
+ * switch to 65001 when it is anything else, verify. All stdio ignored —
505
+ * this either quietly fixes mojibake or quietly declines. Returns whether
506
+ * the console now speaks UTF-8.
507
+ */
508
+ export function ensureUtf8Codepage() {
509
+ try {
510
+ const current = spawnSync('chcp.com', [], { encoding: 'buffer', timeout: 3000 });
511
+ const text = Buffer.concat([current.stdout || Buffer.alloc(0), current.stderr || Buffer.alloc(0)]).toString('latin1');
512
+ const m = /:\s*(\d+)/.exec(text);
513
+ if (m && m[1] === '65001') return true;
514
+ spawnSync('chcp.com', ['65001'], { stdio: 'ignore', timeout: 3000 });
515
+ const again = spawnSync('chcp.com', [], { encoding: 'buffer', timeout: 3000 });
516
+ const text2 = Buffer.concat([again.stdout || Buffer.alloc(0), again.stderr || Buffer.alloc(0)]).toString('latin1');
517
+ const m2 = /:\s*(\d+)/.exec(text2);
518
+ return !!m2 && m2[1] === '65001';
519
+ } catch {
520
+ return false;
521
+ }
522
+ }
523
+
524
+ async function session({ flags }) {
525
+ const client = requireAuth();
526
+ // Classic conhost speaks an OEM codepage by default, so every ›•─│ the CLI
527
+ // paints arrives as mojibake (the whole UI looks broken, not old). One
528
+ // silent `chcp 65001` upgrades the window to UTF-8 in place — same window,
529
+ // nothing for the user to configure. If the console refuses, the session
530
+ // falls back to the classic renderer with ASCII twins instead.
531
+ const classicConhost = process.platform === 'win32' && process.stdout.isTTY
532
+ && !process.env.WT_SESSION && !process.env.TERM_PROGRAM;
533
+ let utf8Console = !classicConhost;
534
+ if (classicConhost) utf8Console = ensureUtf8Codepage();
535
+ // Ask the terminal what it can do while the gateway answers: a DA reply
536
+ // proves escape processing (colors, alt-screen, mouse); silence means a
537
+ // legacy console, and the session degrades honestly instead of painting
538
+ // codes that render as nothing.
539
+ const { probeDevice } = await import('./terminal.js');
540
+ // `let`, not `const`: /model reassigns it via setModel (a const here throws
541
+ // "Assignment to constant variable" on every model switch).
542
+ let [model, device] = await Promise.all([
543
+ resolveModel(client, flags.model),
544
+ probeDevice({}).catch(() => ({ vt: !!process.stdout.isTTY, sixel: false })),
545
+ ]);
546
+ // Degrade on EVIDENCE, not on silence.
547
+ //
548
+ // This used to read "the device probe did not answer within 300ms, therefore this
549
+ // terminal cannot do escape sequences" and set JAITUS_ASCII + JAITUS_NO_COLOR. A
550
+ // terminal that is merely slow to reply — or one that buffers the reply behind
551
+ // something else on stdin — would then draw the whole UI in ASCII twins, which is
552
+ // where the stray `|` characters people were seeing came from: they are the
553
+ // fallbacks for │ ▌ ▏ ▐ appearing in a terminal that renders those glyphs perfectly.
554
+ //
555
+ // A missing reply is now only believed when something else agrees with it: a Windows
556
+ // console that is not Windows Terminal or a known terminal program, whose codepage
557
+ // could not be moved to UTF-8. Anywhere else, absence of evidence is not evidence.
558
+ const knownGoodTerminal = !!(process.env.WT_SESSION || process.env.TERM_PROGRAM
559
+ || process.env.COLORTERM || /256color|xterm|screen|tmux|alacritty|kitty/i.test(process.env.TERM || ''));
560
+ const legacyConsole = !knownGoodTerminal && !device.vt && (classicConhost ? !utf8Console : true);
561
+ if (legacyConsole && process.stdout.isTTY && !process.env.JAITUS_NO_COLOR) {
562
+ process.env.JAITUS_NO_COLOR = '1';
563
+ process.env.JAITUS_ASCII = '1';
564
+ }
565
+ // UTF-8 is the other half of it: conhost with an OEM codepage really does render
566
+ // box-drawing as mojibake, and that is worth the twins even when VT works.
567
+ if (classicConhost && !utf8Console && !process.env.JAITUS_ASCII) process.env.JAITUS_ASCII = '1';
568
+ currentCwd = flags.cwd;
569
+ const skills = discoverSkills(flags.cwd);
570
+ const loaded = flags.resume ? loadSession(flags.resume) : flags.continue ? latestSession({ cwd: flags.cwd }) : null;
571
+ if (flags.resume && !loaded) {
572
+ fail(`No saved session found for ${flags.resume}. Run ${c.bold('jaitus sessions')} to list them.`);
573
+ return 1;
574
+ }
575
+ if (loaded?.model && !flags.model) model = loaded.model;
576
+ const sessionState = loaded || newSession({ cwd: flags.cwd, model });
577
+ sessionState.model = model;
578
+ const creds = loadCredentials();
579
+ let tui = null;
580
+ const permissions = createPermissions({
581
+ autoApprove: flags.yes,
582
+ mode: flags.permissionMode,
583
+ // Saved rules are per project, keyed by directory, because "always allow npm test"
584
+ // in one repo says nothing about what is safe in another.
585
+ ...projectRules(flags.cwd),
586
+ onRulesChange: ({ allow, deny }) => {
587
+ const settings = loadSettings();
588
+ saveSettings({
589
+ ...settings,
590
+ permissionRules: { ...(settings.permissionRules || {}), [flags.cwd]: { allow, deny } },
591
+ });
592
+ },
593
+ onModeChange: (m) => {
594
+ saveSettings({ ...loadSettings(), lastMode: m });
595
+ tui?.setMeta({ mode: m });
596
+ },
597
+ });
598
+ if (loadSettings().lastMode && !flags.yes) permissions.setMode(loadSettings().lastMode);
599
+ // Starting straight into bypass gets one explicit confirmation (§11) when a
600
+ // human is present; headless runs just get the warning on stderr.
601
+ if (permissions.mode === 'bypassPermissions' && !flags.yes) {
602
+ if (process.stdin.isTTY && process.stdout.isTTY) {
603
+ out(`\n ${c.red(c.bold('● BYPASS'))} ${c.red('requested — writes and shell commands would run with no permission checks.')}`);
604
+ const answer = await askKey(` ${c.dim(`Enable bypass mode for this session? [y/N] ${glyph('›', '>')}`)} `, ['y', 'n', '\r', ' ']);
605
+ out('');
606
+ if (answer !== 'y') {
607
+ permissions.setMode('default');
608
+ info('Starting in default mode. Shift+Tab changes it later (bypass asks first).');
609
+ }
610
+ } else {
611
+ err(` ${c.red('●')} bypassPermissions active: permission checks are off for this run.`);
612
+ }
613
+ }
614
+ const messages = sessionState.messages;
615
+ // Totals for this conversation. Saved with the session, so a resumed conversation
616
+ // continues its count instead of starting again from zero.
617
+ const saved = sessionState.spent || {};
618
+ const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0);
619
+ const spent = { input: num(saved.input), cached: num(saved.cached), output: num(saved.output), tools: num(saved.tools), turns: num(saved.turns) };
620
+ sessionState.spent = spent;
621
+ // The real size of the last prompt, as the provider counted it; 0 until one is known.
622
+ let lastContext = num(sessionState.lastContext);
623
+ const sessionStart = Date.now();
624
+
625
+ // Scripted status line (§13): pipe a JSON blob to the user's command and
626
+ // render its first stdout line verbatim above the hint footer.
627
+ let statusTimer = null;
628
+ const refreshStatusLine = (reason) => {
629
+ const cmd = loadSettings().statusLine;
630
+ if (!cmd || !tui) return;
631
+ if (statusTimer) clearTimeout(statusTimer);
632
+ statusTimer = setTimeout(() => {
633
+ try {
634
+ let branch = '';
635
+ try {
636
+ const r = spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: flags.cwd, encoding: 'utf8', timeout: 2000 });
637
+ if (r.status === 0) branch = String(r.stdout || '').trim();
638
+ } catch { /* not a repo */ }
639
+ const blob = {
640
+ model, cwd: flags.cwd, branch,
641
+ ctxTokens: estimateTokens(messages), turns: spent.turns, tools: spent.tools,
642
+ mode: permissions.mode, intent: intentMode, session: sessionState.id,
643
+ elapsedMs: Date.now() - sessionStart, reason,
644
+ };
645
+ const r = spawnSync(cmd, { input: JSON.stringify(blob), shell: true, cwd: flags.cwd, encoding: 'utf8', timeout: 2000 });
646
+ if (r.status === 0 && r.stdout.trim()) tui.setStatusLine(r.stdout.split('\n')[0]);
647
+ } catch { /* a status script never breaks the session */ }
648
+ }, 300);
649
+ };
650
+
651
+ // The header's context meter. The provider's own count of the last prompt when there
652
+ // is one — it is exact, and it includes what the character estimate cannot see (the
653
+ // system prompt, tool schemas). Before any turn in this process, and after anything
654
+ // that rewrites the history, the estimate stands in.
655
+ const showContext = ({ estimate = false } = {}) => {
656
+ const used = !estimate && lastContext ? lastContext : estimateTokens(messages);
657
+ tui?.setMeta?.({ ctx: `${format(used)}/${format(CONTEXT_BUDGET)}`, ctxUsed: used, ctxMax: CONTEXT_BUDGET });
658
+ };
659
+
660
+ const resetContext = () => {
661
+ lastContext = 0;
662
+ sessionState.lastContext = 0;
663
+ showContext();
664
+ };
665
+
666
+ // ── The fullscreen interface ──
667
+ // The alternate-screen app owns a fixed composer and a scrolling transcript. out()
668
+ // routes transcript events through it so command output cannot disturb the composer.
669
+ tui = await createTui({
670
+ // Names AND summaries: the composer menu and the palette both show what a command
671
+ // does beside its name, which is the difference between a list you scan and a list
672
+ // you have to already know.
673
+ slashCommands: [
674
+ ...Object.entries(SLASH)
675
+ .filter(([, value]) => !value.hidden)
676
+ .map(([key, value]) => ({ name: key.split(' ')[0], summary: value.summary })),
677
+ ...Object.entries(skills).map(([name, skill]) => ({ name: `/${name}`, summary: skill.summary || 'project workflow' })),
678
+ ],
679
+ fileProvider: (frag) => completeFiles(frag),
680
+ settings: loadSettings(),
681
+ meta: {
682
+ version: VERSION, cwd: flags.cwd, model, mode: permissions.mode, intent: 'build',
683
+ session: sessionState.id, branch: gitBranch(flags.cwd),
684
+ ctxUsed: 0, ctxMax: CONTEXT_BUDGET,
685
+ },
686
+ onModeCycle: async () => {
687
+ // Bypass is opt-in with both eyes open (§11): stepping into it from the
688
+ // cycle asks first, and declining keeps the current mode.
689
+ const order = ['default', 'acceptEdits', 'plan', 'bypassPermissions'];
690
+ const next = order[(order.indexOf(permissions.mode) + 1) % order.length];
691
+ if (next === 'bypassPermissions') {
692
+ const ans = await tui.askDialog('Enable bypass mode?', [
693
+ { label: 'Yes, skip all checks', value: 'y' },
694
+ { label: 'No, stay put', value: 'n' },
695
+ ], [
696
+ 'Writes and shell commands will run with NO permission prompts.',
697
+ 'Use only in a trusted workspace you can afford to rebuild.',
698
+ ]);
699
+ if (dialogValue(ans) !== 'y') return permissions.mode;
700
+ }
701
+ const m = permissions.cycleMode();
702
+ refreshStatusLine('mode');
703
+ return m;
704
+ },
705
+ });
706
+ setTuiSink(tui);
707
+ activeTui = tui;
708
+
709
+ // Last line of defence. An uncaught exception or an unhandled rejection under the
710
+ // alternate screen goes to a stderr that is not being displayed — the process either
711
+ // dies with no explanation or limps on looking frozen. Both now land in the
712
+ // transcript with their stack, and the session stays usable.
713
+ const onUncaught = (error, origin) => {
714
+ try {
715
+ reportAgentError(error, {
716
+ title: origin === 'unhandledRejection' ? 'Unhandled promise rejection' : 'Uncaught error',
717
+ detail: 'This is a bug in Jaitus, not in your project. Ctrl+D expands the stack; /feedback sends it.',
718
+ });
719
+ tui.setBusy(false);
720
+ } catch {
721
+ // The renderer itself is the thing that failed: leave the screen and say so
722
+ // plainly, because a silent exit here is the worst possible outcome.
723
+ try { tui.stop(); } catch { /* already down */ }
724
+ process.stderr.write(`\n${error?.stack || error}\n`);
725
+ process.exitCode = 1;
726
+ }
727
+ };
728
+ process.on('uncaughtException', (error) => onUncaught(error, 'uncaughtException'));
729
+ process.on('unhandledRejection', (reason) => onUncaught(reason, 'unhandledRejection'));
730
+
731
+ const runTurn = async (signal) => {
732
+ const result = await runAgent({ client, messages, model, cwd: flags.cwd, permissions, signal, tui });
733
+ return result;
734
+ };
735
+
736
+ // Rewind journal: conversation checkpoints per prompt, file snapshots per write.
737
+ const journal = createJournal();
738
+ // Everything that fails outside a tool call. Drained into the next request so the
739
+ // model knows what happened rather than starting each turn as though the session had
740
+ // been uneventful.
741
+ const ledger = createLedger();
742
+
743
+ // Images queued by /image — sent as content blocks with the next user message.
744
+ const pendingImages = [];
745
+ let intentMode = 'build';
746
+ const intentGuidance = {
747
+ build: 'Work as an implementation agent. Inspect the repository, make the requested changes, and verify them within the active permission policy.',
748
+ ask: 'Work in explanation mode. Do not edit files, run mutating commands, or claim a change was made. Explain findings with exact file paths and evidence.',
749
+ architect: 'Work in architecture mode. Do not edit files or run mutating commands. Compare viable designs, interfaces, migration steps, risks, and verification criteria before recommending one.',
750
+ };
751
+ const setIntent = (next) => {
752
+ intentMode = intentGuidance[next] ? next : 'build';
753
+ tui?.setMeta?.({ intent: intentMode });
754
+ };
755
+
756
+ // Esc/Ctrl+C while busy aborts the current run; the loop below notices via the
757
+ // rejected signal and reports the interruption.
758
+ let activeAbort = null;
759
+ tui.onEscape(() => { if (activeAbort) activeAbort.abort(); });
760
+
761
+ /** One agent run with abort wiring, error accounting, and queue draining after. */
762
+ const runManaged = async (messages) => {
763
+ const ctrl = new AbortController();
764
+ activeAbort = ctrl;
765
+ const turnStarted = Date.now();
766
+ try {
767
+ const result = await runAgent({
768
+ client, messages, model, cwd: flags.cwd, permissions, signal: ctrl.signal, tui, journal,
769
+ extraSystem: `${skillCatalog(skills)}\n\nCurrent intent: ${intentMode}. ${intentGuidance[intentMode]}${flags.appendSystemPrompt ? `\n\nAdditional instructions:\n${flags.appendSystemPrompt}` : ''}`,
770
+ systemOverride: flags.systemPrompt || null,
771
+ });
772
+ // An interrupted turn still spent what it spent before Esc — count it.
773
+ if (result && !result.__error) {
774
+ spent.input += result.input || 0;
775
+ spent.cached += result.cached || 0;
776
+ spent.output += result.output || 0;
777
+ spent.tools += result.tools || 0;
778
+ if (result.context) { lastContext = result.context; sessionState.lastContext = lastContext; }
779
+ }
780
+ if (result?.stopReason === 'interrupted') {
781
+ ledger.note('interrupted', 'The user interrupted the previous turn with Esc before it finished. Whatever it was doing did not complete.');
782
+ // Keep the partial assistant turn out of history if it never completed.
783
+ const last = messages[messages.length - 1];
784
+ if (last?.role === 'assistant' && !Array.isArray(last.content)) messages.pop();
785
+ } else if (result && !result.__error) {
786
+ spent.turns += 1;
787
+ // opencode assistant metadata: ▣ Mode · model · duration.
788
+ const secs = ((Date.now() - turnStarted) / 1000).toFixed(1);
789
+ const posture = permissions.mode === 'plan' ? 'Read-only'
790
+ : permissions.mode === 'acceptEdits' ? 'Auto-edit'
791
+ : permissions.mode === 'bypassPermissions' ? 'Off' : 'Ask';
792
+ const meta = ` ${c.brand(glyph('▣', '*'))} ${posture} ${c.dim(`· ${model} · ${secs}s`)}`;
793
+ if (tui) tui.print(meta);
794
+ else out(meta);
795
+ }
796
+ } catch (e) {
797
+ reportAgentError(e);
798
+ // The failed turn is dropped so the next message is not sent on top of a broken
799
+ // conversation the API will reject — but the model is told it happened. Without
800
+ // that, its next turn begins as though nothing went wrong and it re-attempts the
801
+ // thing that just failed.
802
+ ledger.note('turn-failed', `A request to the gateway failed${e.status ? ` with HTTP ${e.status}` : ''}: ${e.message}. That turn was discarded and the user's message was not answered.`);
803
+ if (messages[messages.length - 1]?.role === 'user') messages.pop();
804
+ } finally {
805
+ activeAbort = null;
806
+ sessionState.messages = messages;
807
+ sessionState.model = model;
808
+ saveSession(sessionState);
809
+ // Live context meter for the header: the bar fills, and the numbers beside it
810
+ // say how close the conversation is to needing /compact.
811
+ try {
812
+ showContext();
813
+ tui?.setSpent?.(spent);
814
+ } catch { /* header keeps last value */ }
815
+ refreshStatusLine('turn');
816
+ }
817
+ };
818
+
819
+ /**
820
+ * Nothing thrown while handling a line may escape.
821
+ *
822
+ * The TUI calls this and does not await it, so an escaping rejection is unhandled —
823
+ * and an unhandled rejection under the alternate screen is written to a stderr
824
+ * nobody can see. The session looks like it swallowed the message. Every failure
825
+ * gets reported into the transcript instead, and the prompt comes back.
826
+ */
827
+ const handleLine = async (rawLine) => {
828
+ try {
829
+ await handleLineInner(rawLine);
830
+ } catch (error) {
831
+ reportAgentError(error, { title: 'That message could not be handled' });
832
+ try { tui.setBusy(false); } catch { /* the renderer may already be down */ }
833
+ }
834
+ };
835
+
836
+ /**
837
+ * Summarise the conversation before it runs out of room.
838
+ *
839
+ * Triggered at a fraction of the budget rather than at the wall, because the
840
+ * compaction call itself needs the conversation to still fit. Silent when it is not
841
+ * needed and loud when it happens: a summarised history is a materially different
842
+ * thing to talk to, and finding that out by noticing the agent forgot something is
843
+ * worse than being told.
844
+ */
845
+ const autoCompactIfNeeded = async () => {
846
+ if (loadSettings().autoCompact === false) return;
847
+ const used = estimateTokens(messages);
848
+ if (used < CONTEXT_BUDGET * AUTO_COMPACT_AT) return;
849
+ if (messages.length < 4) return;
850
+
851
+ tui.setStatus?.(`Compacting — ${format(used)} of ${format(CONTEXT_BUDGET)} context used`);
852
+ try {
853
+ const summary = await summarise(client, model, messages, '');
854
+ const dropped = messages.length;
855
+ messages.length = 0;
856
+ messages.push({ role: 'user', content: `[Earlier conversation, summarized:]\n${summary}\n\n[Continue from here.]` });
857
+ tui.notice?.(`Context was ${format(used)}; ${dropped} messages summarised into one. /rewind still has the originals.`, 'info');
858
+ ledger.note('compacted', `The conversation was automatically summarised to free context: ${dropped} messages became one summary. Detail you are not shown above is genuinely gone — ask or re-read rather than assuming.`);
859
+ } catch (error) {
860
+ // Not fatal: the turn can still run, it just runs closer to the ceiling.
861
+ tui.notice?.(`Auto-compaction failed (${error.message}) — continuing without it. /compact retries.`, 'warn');
862
+ ledger.note('compact-failed', `Automatic context compaction failed (${error.message}); the conversation is close to the context limit.`);
863
+ } finally {
864
+ tui.clearStatus?.();
865
+ }
866
+ };
867
+
868
+ const handleLineInner = async (rawLine) => {
869
+ const input = rawLine.trim();
870
+ if (!input) return;
871
+ // First submit leaves home: the composer goes full session width.
872
+ tui.setHome?.(false);
873
+
874
+ // Echo the submission as a first-class entry rather than two pre-formatted lines,
875
+ // so it wraps, re-wraps on resize, and carries its attachments.
876
+ if (tui.user) tui.user(input, pendingImages.map((i) => i.name).filter(Boolean));
877
+ else {
878
+ const bar = c.brand(glyph('│', '|'));
879
+ tui.print(`\n ${bar} ${c.bold(input.replace(/\n/g, ' '))}`, 'user');
880
+ tui.print(` ${bar}`, 'user');
881
+ }
882
+
883
+ // Conversation checkpoint for Esc+Esc rewind — before the turn that can change it.
884
+ if (!input.startsWith('/') && !input.startsWith('#') && !input.startsWith('!')) {
885
+ journal.checkpoint(messages);
886
+ }
887
+
888
+ if (input.startsWith('/')) {
889
+ const [verb, ...args] = input.split(/\s+/);
890
+ let result;
891
+ try {
892
+ result = await slashCommand(verb, args, {
893
+ client, messages, permissions, flags, spent, resetContext,
894
+ model, setModel: (m) => {
895
+ model = m;
896
+ sessionState.model = m;
897
+ tui?.setMeta?.({ model: m });
898
+ },
899
+ skills, sessionState, runManaged, tui, pendingImages, journal, setIntent,
900
+ });
901
+ } catch (error) {
902
+ // Slash commands run inside the fullscreen app. Letting this escape to the
903
+ // process-level handler prints to stderr outside the alternate screen and
904
+ // makes a real failure look like nothing happened.
905
+ reportAgentError(error);
906
+ }
907
+ if (result === 'exit') { setTuiSink(null); tui.stop(); process.exit(0); }
908
+ return;
909
+ }
910
+
911
+ // # <text> appends to the project's JAITUS.md — a memory note, not a prompt.
912
+ if (input.startsWith('#')) {
913
+ const note = input.slice(1).trim();
914
+ if (note) {
915
+ appendMemory(flags.cwd, note);
916
+ ok(`Remembered in ${CONTEXT_FILE}: ${c.bold(note.slice(0, 60))}${note.length > 60 ? '…' : ''}`);
917
+ } else {
918
+ const existing = loadContext(flags.cwd);
919
+ out(existing ? existing : c.dim(`(no ${CONTEXT_FILE} yet — type "# note text" to add one)`));
920
+ }
921
+ return;
922
+ }
923
+
924
+ tui.setBusy(true);
925
+ try {
926
+ const hook = await runHook(flags.cwd, 'beforePrompt', { prompt: input, sessionId: sessionState.id });
927
+ if (!hook.ok) {
928
+ fail(`beforePrompt hook stopped the request (exit ${hook.code}).`);
929
+ ledger.note('hook', `The project's beforePrompt hook blocked a request (exit ${hook.code}). The user's message was never sent.`);
930
+ return;
931
+ }
932
+ // ! <command> runs a shell command directly and feeds the output to the model.
933
+ // A trailing `&` detaches it into a background job instead (§16).
934
+ if (input.startsWith('!')) {
935
+ let command = input.slice(1).trim();
936
+ if (!command) { warn('Give a command: ! git status'); return; }
937
+ if (/\s&\s*$/.test(command)) {
938
+ command = command.replace(/\s&\s*$/, '');
939
+ const { startBackground } = await import('./background.js');
940
+ const job = startBackground(command, flags.cwd);
941
+ ok(`Background job ${c.bold(job.id)} started: ${c.dim(command.slice(0, 80))} — output streams to its log; /tasks lists it.`);
942
+ info(`Read it with the background_read tool ("job ${job.id}") or stop it with background_stop.`);
943
+ return;
944
+ }
945
+ const started = Date.now();
946
+ const code = await runShell(command, flags.cwd);
947
+ if (code === 0) {
948
+ messages.push({ role: 'user', content: `I ran \`${command}\` myself; its output is above. Comment on it briefly.` });
949
+ await runManaged(messages);
950
+ } else {
951
+ info(`exit ${code} in ${((Date.now() - started) / 1000).toFixed(1)}s — output above, not sent to the model.`);
952
+ // The output is deliberately not sent (it may be huge, and the user ran it
953
+ // for themselves), but the fact that it failed is context the model needs —
954
+ // otherwise it will suggest running the very command that just failed.
955
+ ledger.note('shell', `The user ran \`${command}\` themselves and it exited ${code}. The output was not shared with you; ask for it if it matters.`);
956
+ }
957
+ return;
958
+ }
959
+
960
+ // Context is finite and running out of it mid-task is the worst moment to find
961
+ // out. Compacting a turn early costs one cheap summarisation call; compacting a
962
+ // turn late costs the turn, and sometimes the thread of what was being done.
963
+ await autoCompactIfNeeded();
964
+
965
+ // @path in a message is expanded to the file's contents here rather than left
966
+ // for the agent to fetch. The agent CAN read it, but that is a whole round trip
967
+ // to learn something the user already told it, and on a metered gateway the
968
+ // round trip is the expensive part.
969
+ const mentioned = expandMentions(input, flags.cwd);
970
+ if (mentioned.blocks.length) {
971
+ for (const note of mentioned.notes) tui.notice?.(note, 'dim');
972
+ }
973
+
974
+ // Image chips pasted with Ctrl+V ride first, then the text.
975
+ const pasted = tui.drainAttachments ? tui.drainAttachments(input) : [];
976
+ const queuedImages = [...pendingImages.splice(0), ...pasted];
977
+ if (queuedImages.length || mentioned.blocks.length) {
978
+ messages.push({ role: 'user', content: [...queuedImages, ...mentioned.blocks, { type: 'text', text: input }] });
979
+ } else {
980
+ messages.push({ role: 'user', content: input });
981
+ }
982
+ await runManaged(messages);
983
+ } catch (error) {
984
+ // There was no catch here, only a finally. Anything thrown between setBusy(true)
985
+ // and the end of the block — a hook, a background spawn, an attachment read —
986
+ // escaped `handleLine`, and because nothing awaits it, became an unhandled
987
+ // rejection printed to a stderr the alternate screen was covering. The session
988
+ // simply appeared to ignore the message.
989
+ reportAgentError(error, { title: 'That turn did not run' });
990
+ } finally {
991
+ tui.setBusy(false);
992
+ // While the agent was running, Enter queued whatever was typed — send those
993
+ // now, one at a time, each itself interruptible.
994
+ for (;;) {
995
+ const queued = tui.takeQueued()[0];
996
+ if (queued === undefined) break;
997
+ messages.push({ role: 'user', content: queued });
998
+ // Through the same entry type as a typed message. This was two pre-formatted
999
+ // lines each carrying a `│`, pushed in as raw text — which is where the doubled
1000
+ // bar people saw after interrupting came from: the queue drains in the `finally`
1001
+ // of an interrupted turn, so that is exactly when it showed up.
1002
+ if (tui.user) tui.user(queued);
1003
+ else tui.print(`\n ${c.brand(glyph('│', '|'))} ${c.bold(queued.replace(/\n/g, ' '))}`, 'user');
1004
+ tui.setBusy(true);
1005
+ await runManaged(messages);
1006
+ tui.setBusy(false);
1007
+ }
1008
+ await runHook(flags.cwd, 'afterTurn', { prompt: input, sessionId: sessionState.id });
1009
+ }
1010
+ };
1011
+
1012
+ useHistoryFile(HISTORY);
1013
+ // ── Which renderer ──
1014
+ //
1015
+ // This used to read: if the device-attributes probe did not answer, do not use the
1016
+ // app view. A probe that goes unanswered is common — a terminal under a multiplexer,
1017
+ // over SSH, or with anything else queued on stdin — and the consequence was total:
1018
+ // no app view, no logo, no mouse, no transcript. Just a readline prompt. The whole
1019
+ // interface silently did not exist, and nothing said why.
1020
+ //
1021
+ // The test is now for positive evidence AGAINST the app view rather than for the
1022
+ // absence of evidence for it. A real terminal gets the real interface; only a console
1023
+ // that has actually shown it cannot cope falls back.
1024
+ const savedTui = loadSettings().tui;
1025
+ const forcedFullscreen = process.env.JAITUS_FULLSCREEN === '1' || process.env.JAITUS_NO_FLICKER === '1'
1026
+ || savedTui === 'fullscreen';
1027
+ const wantFullscreen = process.env.JAITUS_NO_FULLSCREEN ? false
1028
+ : forcedFullscreen ? true
1029
+ : savedTui === 'default' ? false
1030
+ // The one case that genuinely cannot render it: a classic Windows console whose
1031
+ // codepage could not be moved to UTF-8, where every box-drawing character lands as
1032
+ // mojibake. JAITUS_FULLSCREEN=1 still forces it for the brave.
1033
+ : classicConhost && !utf8Console ? false
1034
+ // A console that answered nothing AND looks like nothing — no WT_SESSION, no
1035
+ // TERM_PROGRAM, no COLORTERM, no recognisable TERM — is the legacy case. Anywhere
1036
+ // else, silence from the probe means the probe was not heard, not that the terminal
1037
+ // is incapable.
1038
+ : !device.vt && !knownGoodTerminal && process.stdout.isTTY ? false
1039
+ : true;
1040
+ if (process.stdout.isTTY && wantFullscreen) {
1041
+ tui.start({ onLine: handleLine, history: sessionHistory() });
1042
+ // Fresh sessions open on the home moment, opencode-style: the centered
1043
+ // two-tone mark, then a tip — the composer below is the whole welcome.
1044
+ // The mark is vertically centered in the free space above the pinned
1045
+ // composer, the way home centers it; it scrolls away on first send.
1046
+ // Resumed sessions replay their conversation instead (below).
1047
+ if (!loaded && !process.env.JAITUS_NO_BANNER) {
1048
+ // The app view owns its own welcome: it draws the wordmark through a real
1049
+ // per-character gradient and centres it against the live window height, neither
1050
+ // of which can be done by pushing pre-coloured strings into the transcript —
1051
+ // the escape codes would have to be shredded mid-frame, and the padding would
1052
+ // be wrong the moment the terminal was resized.
1053
+ tui.setHome?.(true);
1054
+ if (!tui.user) {
1055
+ const logo = renderHomeLogo({ version: VERSION, width: columns() });
1056
+ for (const line of logo) tui.print(line);
1057
+ tui.print(c.dim(` Tip: ${startupTip()} · /help lists everything`));
1058
+ }
1059
+ }
1060
+ // (There used to be a notice here saying the wheel could not reach the app on a
1061
+ // classic Windows console. The app requests mouse reporting itself now, so it can;
1062
+ // and the line was landing in the transcript, which suppressed the whole welcome
1063
+ // screen — it is how the logo came to be invisible. The status bar already carries
1064
+ // the keys that scroll.)
1065
+ // The cascade already announced fresh sessions; resumed ones (and
1066
+ // --no-banner runs) still need their one status line.
1067
+ if (loaded || process.env.JAITUS_NO_BANNER) {
1068
+ const line = `Session ${sessionState.id.slice(0, 8)}${loaded ? ' resumed' : ' ready'} · ${flags.cwd}`;
1069
+ if (tui.notice) tui.notice(line); else tui.print(c.dim(` ${line}`));
1070
+ }
1071
+ // A resumed session replays its old conversation onto the screen, so the
1072
+ // user lands inside the same session they left — not a blank transcript.
1073
+ if (loaded && messages.length) replaySession(tui, messages);
1074
+ // A resumed conversation already has a size and a history of spend. Showing 0 until
1075
+ // the first new turn finished made both look broken.
1076
+ showContext();
1077
+ tui.setSpent?.(spent);
1078
+ refreshStatusLine('start');
1079
+
1080
+ /** Esc+Esc: pick a checkpoint, restore the conversation and the files. */
1081
+ const doRewind = async () => {
1082
+ const points = journal.list().reverse(); // newest first for the dialog
1083
+ if (!points.length) { tui.print(c.yellow(' Nothing to rewind — send a message first.')); return; }
1084
+ const options = points.slice(0, 9).map((p) => ({
1085
+ value: p.index,
1086
+ label: `Turn ${p.index + 1}: ${(p.prompt || '(no prompt)').slice(0, 60)}${p.files ? c.dim(` · ${p.files} file change${p.files === 1 ? '' : 's'} after`) : ''}`,
1087
+ }));
1088
+ const answer = await tui.askDialog(
1089
+ `${c.bold('Rewind to an earlier turn?')} ${c.dim('(restores messages and files)')}`,
1090
+ [...options, { value: null, label: c.dim('Keep current state') }],
1091
+ [c.dim('Files the agent changed after the chosen turn are restored to their earlier contents.')],
1092
+ );
1093
+ const chosen = dialogValue(answer);
1094
+ if (chosen === null || chosen === undefined) { tui.print(c.dim(' ↩ Still on the current timeline.')); return; }
1095
+ const outcome = journal.restore(chosen, messages);
1096
+ if (!outcome.ok) { tui.print(c.red(` ✗ Rewind failed: ${outcome.error}`)); return; }
1097
+ sessionState.messages = messages;
1098
+ resetContext();
1099
+ saveSession(sessionState);
1100
+ ledger.note('rewind', `The user rewound the session to turn ${chosen + 1}. ${outcome.restored.length} file(s) were reverted on disk${outcome.deleted.length ? ` and ${outcome.deleted.length} removed` : ''} — anything you believed about their contents after that turn is no longer true.`);
1101
+ tui.print(c.green(` ↩ Rewound to turn ${chosen + 1} — ${outcome.messageCount} message${outcome.messageCount === 1 ? '' : 's'} restored`)
1102
+ + (outcome.restored.length || outcome.deleted.length
1103
+ ? c.dim(` · ${outcome.restored.length} file${outcome.restored.length === 1 ? '' : 's'} reverted${outcome.deleted.length ? `, ${outcome.deleted.length} removed` : ''}`)
1104
+ : ''));
1105
+ for (const f of [...outcome.restored, ...outcome.deleted].slice(0, 5)) {
1106
+ tui.print(c.dim(` ${displayPath(f)}`));
1107
+ }
1108
+ };
1109
+ tui.onRewind(doRewind);
1110
+ // Clicking an indicator runs the command that indicator is about. It goes through
1111
+ // handleLine rather than straight to slashCommand so a clicked command is
1112
+ // indistinguishable from a typed one — same echo, same error handling, same history.
1113
+ tui.onCommand?.((command) => { void handleLine(command); });
1114
+ // Drag-to-select copies through the same clipboard path as /copy, so there is one
1115
+ // place that knows how to reach a clipboard on each platform.
1116
+ tui.onCopy?.((text) => copyToClipboard(text));
1117
+ } else if (!unicode() && !process.env.JAITUS_NO_BANNER) {
1118
+ // Plain-console open, codex-style session header: lowercase wordmark,
1119
+ // model, approval posture, directory, account — one compact block,
1120
+ // instant, nothing repeated.
1121
+ const { renderClassicOpen } = await import('./brand.js');
1122
+ const { columns: termColumns } = await import('./ui.js');
1123
+ const approval = permissions.mode === 'plan' ? 'read-only'
1124
+ : permissions.mode === 'acceptEdits' ? 'auto-edit'
1125
+ : permissions.mode === 'bypassPermissions' ? 'off' : 'ask';
1126
+ for (const line of renderClassicOpen({
1127
+ version: VERSION, model, approval, cwd: flags.cwd,
1128
+ account: creds?.user?.email ? `${creds.user.email}${creds.user.isPaid ? '' : ' (free plan)'}` : null,
1129
+ session: `${sessionState.id.slice(0, 8)}${loaded ? ' resumed' : ''}`,
1130
+ narrow: termColumns() < 60,
1131
+ })) out(line);
1132
+ out('');
1133
+ if (loaded && messages.length) replaySession(tui, messages);
1134
+ refreshStatusLine('start');
1135
+ for (;;) {
1136
+ const line = await promptLine(`${c.brand(glyph('›', '>'))} `, {
1137
+ completions: SLASH_NAMES,
1138
+ fileComplete: true,
1139
+ onShiftTab: () => {
1140
+ const mode = permissions.cycleMode();
1141
+ info(`Permission mode: ${c.bold(mode)}` + (mode === 'plan' ? ' — writes and commands are blocked' : mode === 'bypassPermissions' ? ' — everything allowed' : ''));
1142
+ return mode;
1143
+ },
1144
+ });
1145
+ if (line === null) { out(''); return 0; }
1146
+ await handleLine(line);
1147
+ if (tui.exited) break;
1148
+ }
1149
+ return 0;
1150
+ } else {
1151
+ await banner({ version: VERSION });
1152
+ // The probe heard nothing back: this console ignores escape sequences,
1153
+ // so colors and the app view are off and plain text is the honest UI.
1154
+ // Said once ever (flagged in settings), then never again.
1155
+ if (!device.vt && process.stdout.isTTY && !savedTui && !loadSettings().legacyNoted) {
1156
+ out(' Plain-text console: for colors, mouse and fullscreen, use Windows Terminal.');
1157
+ try { saveSettings({ ...loadSettings(), legacyNoted: true }); } catch { /* notice is courtesy, not contract */ }
1158
+ }
1159
+ const projectNotes = loadContext(flags.cwd);
1160
+ table([
1161
+ [` ${c.dim('directory')}`, flags.cwd],
1162
+ [` ${c.dim('model')}`, model],
1163
+ [` ${c.dim('session')}`, `${sessionState.id}${loaded ? ' · resumed' : ''}`],
1164
+ ...(creds?.user?.email ? [[` ${c.dim('account')}`, `${creds.user.email}${creds.user.isPaid ? '' : c.dim(' · free plan')}`]] : []),
1165
+ ...(projectNotes ? [[` ${c.dim('context')}`, `${CONTEXT_FILE} (${projectNotes.length} characters)`]] : []),
1166
+ ]);
1167
+ out('');
1168
+ out(` ${c.dim('/help for commands · Shift+Tab cycles modes · \\+Enter newline · /exit to leave')}`);
1169
+
1170
+ // Inline fallback: same handling, one promptLine at a time.
1171
+ for (;;) {
1172
+ const modeTag = permissions.mode === 'default' ? ''
1173
+ : permissions.mode === 'bypassPermissions' ? c.red(` [${permissions.mode}] `)
1174
+ : c.yellow(` [${permissions.mode}] `);
1175
+ const line = await promptLine(`${c.brand(glyph('›', '>'))} ${modeTag}`, {
1176
+ completions: SLASH_NAMES,
1177
+ fileComplete: true,
1178
+ onShiftTab: () => {
1179
+ const mode = permissions.cycleMode();
1180
+ info(`Permission mode: ${c.bold(mode)}` + (mode === 'plan' ? ' — writes and commands are blocked' : mode === 'bypassPermissions' ? ' — everything allowed' : ''));
1181
+ return mode;
1182
+ },
1183
+ });
1184
+ // Ctrl+D / closed input never leaves either: only /exit does. A real
1185
+ // EOF (piped stdin ending) still ends the loop.
1186
+ if (line === null) {
1187
+ if (process.stdin.isTTY) { out(` ${c.dim('Type /exit to leave')}`); continue; }
1188
+ out(''); return 0;
1189
+ }
1190
+ await handleLine(line);
1191
+ if (tui.exited) break;
1192
+ }
1193
+ return 0;
1194
+ }
1195
+
1196
+ // TUI mode runs until the key handler sets shouldExit (Ctrl+C/Ctrl+D on empty).
1197
+ await new Promise((resolve) => {
1198
+ const iv = setInterval(() => {
1199
+ if (tui.exited) { clearInterval(iv); resolve(); }
1200
+ }, 120);
1201
+ const cleanup = () => { clearInterval(iv); resolve(); };
1202
+ process.once('SIGINT', cleanup);
1203
+ });
1204
+ setTuiSink(null);
1205
+ tui.stop();
1206
+ out('');
1207
+ return 0;
1208
+ }
1209
+
1210
+ /** Files and directories matching an '@' fragment — same as the inline completer. */
1211
+ function completeFiles(frag) {
1212
+ try {
1213
+ const dir = frag.includes('/') ? frag.slice(0, frag.lastIndexOf('/') + 1) : '';
1214
+ const base = frag.slice(dir.length);
1215
+ const entries = fs.readdirSync(path.resolve(currentCwd, dir || '.'), { withFileTypes: true });
1216
+ return entries
1217
+ .filter((e) => e.name.startsWith(base) && !e.name.startsWith('.'))
1218
+ .map((e) => dir + e.name + (e.isDirectory() ? '/' : ''))
1219
+ .slice(0, 12);
1220
+ } catch {
1221
+ return [];
1222
+ }
1223
+ }
1224
+
1225
+ // The cwd the @-completer reads from; set when a session starts.
1226
+ let currentCwd = process.cwd();
1227
+
1228
+ /** Prior prompts for Up-arrow recall: this session first, then earlier ones. */
1229
+ function sessionHistory() {
1230
+ try {
1231
+ const file = path.join(HOME, 'history');
1232
+ if (!fs.existsSync(file)) return [];
1233
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
1234
+ return Array.isArray(parsed) ? parsed.slice(-50).reverse() : [];
1235
+ } catch {
1236
+ return [];
1237
+ }
1238
+ }
1239
+
1240
+ /** Append one line to the project memory file, creating it with a header if needed. */
1241
+ function appendMemory(cwd, note) {
1242
+ const file = path.join(cwd, CONTEXT_FILE);
1243
+ try {
1244
+ if (!fs.existsSync(file)) fs.writeFileSync(file, `# ${CONTEXT_FILE}\n\nNotes for the Jaitus agent working in this directory.\n\n`, 'utf8');
1245
+ fs.appendFileSync(file, `- ${note}\n`, 'utf8');
1246
+ } catch (e) {
1247
+ fail(`Could not write ${CONTEXT_FILE}: ${e.message}`);
1248
+ }
1249
+ }
1250
+
1251
+ /** Run a shell command with output streaming to the terminal. Returns the exit code. */
1252
+ function runShell(command, cwd) {
1253
+ return new Promise((resolve) => {
1254
+ const child = spawn(command, {
1255
+ cwd,
1256
+ shell: true,
1257
+ stdio: ['ignore', 'pipe', 'pipe'],
1258
+ });
1259
+ child.stdout.on('data', (d) => process.stdout.write(d));
1260
+ child.stderr.on('data', (d) => process.stderr.write(d));
1261
+ child.on('error', (e) => { fail(e.message); resolve(1); });
1262
+ child.on('close', (code) => resolve(code ?? 1));
1263
+ });
1264
+ }
1265
+
1266
+ /** Capture a fixed executable invocation for an in-app panel; no shell interpolation. */
1267
+ function captureProcess(command, args, cwd) {
1268
+ return new Promise((resolve) => {
1269
+ const child = spawn(command, args, { cwd, shell: false, stdio: ['ignore', 'pipe', 'pipe'] });
1270
+ let stdout = ''; let stderr = '';
1271
+ child.stdout.on('data', (chunk) => { stdout += String(chunk); });
1272
+ child.stderr.on('data', (chunk) => { stderr += String(chunk); });
1273
+ child.on('error', (error) => resolve({ stdout, stderr, error }));
1274
+ child.on('close', (code) => resolve({ stdout, stderr, code, error: code === 0 ? null : new Error(stderr || `exit ${code}`) }));
1275
+ });
1276
+ }
1277
+
1278
+ /**
1279
+ * Switch renderers in place (§2): persist the choice, checkpoint the
1280
+ * conversation to disk, leave the alternate screen cleanly, and exec the CLI
1281
+ * again resumed on the same session — the user loses nothing but a blink.
1282
+ */
1283
+ async function relaunchRenderer(ctx, want) {
1284
+ const { saveSettings, loadSettings } = await import('./config.js');
1285
+ const { saveSession } = await import('./sessions.js');
1286
+ const { spawnSync } = await import('node:child_process');
1287
+ const { fileURLToPath } = await import('node:url');
1288
+ saveSettings({ ...loadSettings(), tui: want });
1289
+ ctx.sessionState.messages = ctx.messages;
1290
+ ctx.sessionState.model = ctx.model;
1291
+ saveSession(ctx.sessionState);
1292
+ setTuiSink(null);
1293
+ ctx.tui.stop();
1294
+ const bin = fileURLToPath(new URL('../bin/jaitus.js', import.meta.url));
1295
+ const argv = process.argv.slice(2).filter((a) => a !== '--resume' && a !== '-r' && a !== '--continue' && a !== '-c');
1296
+ // Drop the value that followed --resume/-r, if any.
1297
+ const cleaned = [];
1298
+ for (let i = 0; i < argv.length; i++) {
1299
+ if ((argv[i] === '--resume' || argv[i] === '-r')) { i++; continue; }
1300
+ cleaned.push(argv[i]);
1301
+ }
1302
+ out(c.dim(` Relaunching in ${want} mode — conversation continues where it left off…`));
1303
+ const res = spawnSync(process.execPath, [bin, ...cleaned, '--resume', ctx.sessionState.id], { stdio: 'inherit', cwd: ctx.flags.cwd });
1304
+ process.exit(res.status ?? 0);
1305
+ }
1306
+
1307
+ /**
1308
+ * Single-select picker for anything listable (sessions, models, modes, jobs):
1309
+ * arrows + Enter, 1-9 jump, Esc cancels. Returns the value or undefined.
1310
+ */
1311
+ async function pickOption(tui, title, question, options, preview = []) {
1312
+ if (!options.length) return undefined;
1313
+ const r = await tui.askDialog(question, options.slice(0, 9), preview, { title, hideComment: true });
1314
+ const value = r && typeof r === 'object' ? r.value : r;
1315
+ return value == null ? undefined : value;
1316
+ }
1317
+ function dialogValue(answer) {
1318
+ return answer && typeof answer === 'object' ? answer.value : answer;
1319
+ }
1320
+
1321
+ async function slashCommand(verb, args, ctx) {
1322
+ const { client, messages, flags, spent } = ctx;
1323
+
1324
+ switch (verb) {
1325
+ case '/exit':
1326
+ case '/quit':
1327
+ return 'exit';
1328
+
1329
+ case '/help':
1330
+ out('');
1331
+ for (const [name, s] of Object.entries(SLASH)) out(` ${name.padEnd(18)}${c.dim(s.summary)}`);
1332
+ out('');
1333
+ info(`${c.dim('Prefixes:')} ${c.bold('# note')} ${c.dim('→ project memory ·')} ${c.bold('! cmd')} ${c.dim('→ run a shell command ·')} ${c.bold('@file')} ${c.dim('→ mention a file ·')} ${c.bold('\\+Enter')} ${c.dim('→ newline')}`);
1334
+ return null;
1335
+
1336
+ case '/status': {
1337
+ const creds = loadCredentials();
1338
+ table([
1339
+ [` ${c.dim('model')}`, ctx.model],
1340
+ [` ${c.dim('mode')}`, ctx.permissions.mode],
1341
+ [` ${c.dim('account')}`, creds?.user?.email || 'not signed in'],
1342
+ [` ${c.dim('directory')}`, ctx.flags.cwd],
1343
+ [` ${c.dim('context')}`, `${ctx.messages.length} messages · ~${estimateTokens(ctx.messages).toLocaleString()} tokens`],
1344
+ [` ${c.dim('session')}`, `${ctx.spent.turns} turns · ${ctx.spent.tools} tool calls`],
1345
+ ]);
1346
+ return null;
1347
+ }
1348
+
1349
+ case '/context': {
1350
+ const tokens = estimateTokens(ctx.messages);
1351
+ table([
1352
+ [` ${c.dim('messages')}`, String(ctx.messages.length)],
1353
+ [` ${c.dim('user messages')}`, String(ctx.messages.filter((m) => m.role === 'user' && typeof m.content === 'string').length)],
1354
+ [` ${c.dim('assistant turns')}`, String(ctx.messages.filter((m) => m.role === 'assistant').length)],
1355
+ [` ${c.dim('tool results')}`, String(ctx.messages.reduce((n, m) => n + (Array.isArray(m.content) ? m.content.filter((b) => b.type === 'tool_result').length : 0), 0))],
1356
+ [` ${c.dim('estimated tokens')}`, tokens.toLocaleString()],
1357
+ ...(loadContext(ctx.flags.cwd) ? [[` ${c.dim('project notes')}`, `${CONTEXT_FILE} (loaded)`]] : []),
1358
+ ]);
1359
+ info(`${c.dim('Run')} ${c.bold('/compact')} ${c.dim('to summarize and free context when this grows.')}`);
1360
+ return null;
1361
+ }
1362
+
1363
+ case '/compact': {
1364
+ if (!ctx.messages.length) { info('Nothing to compact — the conversation is empty.'); return null; }
1365
+ // In fullscreen the shared spinner would scribble over the frame —
1366
+ // report through the app status row instead.
1367
+ const spin = ctx.tui
1368
+ ? { stop: () => ctx.tui.clearStatus(), update: () => {} }
1369
+ : (await import('./ui.js')).spinner('Summarizing…');
1370
+ if (ctx.tui) ctx.tui.setStatus(`${c.brand(glyph('◆', '*'))} ${c.brand(glyph('Summarizing…', 'Summarizing...'))}`);
1371
+ try {
1372
+ const text = await summarise(ctx.client, ctx.model, ctx.messages, args.join(' '));
1373
+ ctx.messages.length = 0;
1374
+ ctx.messages.push({ role: 'user', content: `[Earlier conversation, summarized:]\n${text}\n\n[Continue from here.]` });
1375
+ ok('Conversation compacted. Context freed.');
1376
+ try { ctx.resetContext?.(); } catch { /* header keeps last value */ }
1377
+ } catch (e) {
1378
+ fail(`Compact failed: ${e.message}`);
1379
+ } finally {
1380
+ spin.stop();
1381
+ }
1382
+ return null;
1383
+ }
1384
+
1385
+ case '/export': {
1386
+ const target = args[0] || `jaitus-session-${new Date().toISOString().slice(0, 16).replace(/[:T]/g, '-')}.md`;
1387
+ try {
1388
+ const lines = [`# Jaitus session — ${new Date().toISOString()}`, ''];
1389
+ for (const m of ctx.messages) {
1390
+ if (m.role === 'user' && typeof m.content === 'string') lines.push(`## You\n\n${m.content}\n`);
1391
+ else if (m.role === 'assistant') {
1392
+ const text = (Array.isArray(m.content) ? m.content : [])
1393
+ .filter((b) => b.type === 'text').map((b) => b.text).join('\n');
1394
+ const tools = (Array.isArray(m.content) ? m.content : []).filter((b) => b.type === 'tool_use');
1395
+ if (text) lines.push(`## Jaitus\n\n${text}\n`);
1396
+ for (const t of tools) lines.push(`> ${c.grey('')}\`${t.name} ${JSON.stringify(t.input).slice(1, 120)}\``);
1397
+ }
1398
+ }
1399
+ fs.writeFileSync(path.resolve(ctx.flags.cwd, target), lines.join('\n'), 'utf8');
1400
+ ok(`Exported to ${c.bold(target)}`);
1401
+ } catch (e) {
1402
+ fail(`Export failed: ${e.message}`);
1403
+ }
1404
+ return null;
1405
+ }
1406
+
1407
+ case '/copy': {
1408
+ const last = [...ctx.messages].reverse().find((m) => m.role === 'assistant'
1409
+ && Array.isArray(m.content) && m.content.some((b) => b.type === 'text' && b.text.trim()));
1410
+ const text = last ? last.content.filter((b) => b.type === 'text').map((b) => b.text).join('\n') : '';
1411
+ if (!text) { warn('No response to copy yet.'); return null; }
1412
+ const copied = await copyToClipboard(text);
1413
+ if (copied) ok('Last response copied to the clipboard.');
1414
+ else warn('Clipboard not available — run /export to save the conversation instead.');
1415
+ return null;
1416
+ }
1417
+
1418
+ case '/image': {
1419
+ const [p] = args;
1420
+ if (!p) { warn('Give an image path: /image screenshot.png (png, jpg, gif, webp — max ~3 MB).'); return null; }
1421
+ const abs = path.isAbsolute(p) ? p : path.join(ctx.flags.cwd, p);
1422
+ const block = await imageBlock(abs);
1423
+ if (!block) { warn(`Cannot attach ${p} — not a supported image (png/jpg/jpeg/gif/webp), too big, or unreadable.`); return null; }
1424
+ ctx.pendingImages.push(block);
1425
+ ok(`Image attached (${Math.round((block.source.data.length * 3) / 4 / 1024)} KB). It is sent with your next message.`);
1426
+ return null;
1427
+ }
1428
+
1429
+ case '/diff': {
1430
+ const [stat, diff] = await Promise.all([
1431
+ captureProcess('git', ['diff', '--stat'], ctx.flags.cwd),
1432
+ captureProcess('git', ['diff', '--no-ext-diff', '--unified=3'], ctx.flags.cwd),
1433
+ ]);
1434
+ if (stat.error || diff.error) { warn('Not a git repository, or git is not installed.'); return null; }
1435
+ const body = `${stat.stdout}${stat.stdout && diff.stdout ? '\n\n' : ''}${diff.stdout}`.trim()
1436
+ || 'No uncommitted changes.';
1437
+ if (ctx.tui) await ctx.tui.showPanel('Changes', body.split('\n'));
1438
+ else out(body);
1439
+ return null;
1440
+ }
1441
+
1442
+ case '/tasks': {
1443
+ const activity = ctx.tui?.activitySnapshot?.() || [];
1444
+ const { listBackground } = await import('./background.js');
1445
+ const jobs = listBackground();
1446
+ if (!activity.length && !jobs.length) { info('No agent tool activity in this session yet.'); return null; }
1447
+ const lines = activity.flatMap((item) => [
1448
+ `${item.status === 'ok' ? '✓' : item.status === 'failed' ? '✗' : '…'} ${item.label}`,
1449
+ ...(item.detail ? item.detail.split('\n').slice(0, 12).map((line) => ` ${line}`) : []),
1450
+ '',
1451
+ ]);
1452
+ if (jobs.length) {
1453
+ lines.push(c.bold('Background jobs'), ...jobs.map((j) => ` ${j.running ? '…' : j.exit === 0 ? '✓' : '✗'} ${j.id} ${j.command} (pid ${j.pid ?? '?'})`), '');
1454
+ }
1455
+ if (ctx.tui) await ctx.tui.showPanel('Session activity', lines);
1456
+ else out(lines.join('\n'));
1457
+ return null;
1458
+ }
1459
+
1460
+ case '/memory': {
1461
+ const note = args.join(' ').trim();
1462
+ if (note) { appendMemory(ctx.flags.cwd, note); ok(`Remembered in ${CONTEXT_FILE}.`); return null; }
1463
+ const existing = loadContext(ctx.flags.cwd);
1464
+ out(existing ? existing : c.dim(`(no ${CONTEXT_FILE} yet — /memory <text> creates one)`));
1465
+ return null;
1466
+ }
1467
+
1468
+ case '/permissions': {
1469
+ const MODES = {
1470
+ default: 'ask before every write and command',
1471
+ acceptEdits: 'file edits auto-approved, commands still ask',
1472
+ plan: 'read-only: inspect and propose, change nothing',
1473
+ bypassPermissions: 'no checks at all — trusted workspaces only',
1474
+ };
1475
+ const applyMode = async (next) => {
1476
+ const real = next === 'auto' ? 'bypassPermissions' : next;
1477
+ if ((real === 'bypassPermissions') && ctx.tui && ctx.permissions.mode !== 'bypassPermissions') {
1478
+ const ans = await ctx.tui.askDialog('Enable bypass mode?', [
1479
+ { label: 'Yes, skip all checks', value: 'y' },
1480
+ { label: 'No, stay put', value: 'n' },
1481
+ ], [
1482
+ 'Writes and shell commands will run with NO permission prompts.',
1483
+ 'Use only in a trusted workspace you can afford to rebuild.',
1484
+ ], { title: 'Bypass mode', hideComment: true });
1485
+ if (dialogValue(ans) !== 'y') { info(`Staying in ${c.bold(ctx.permissions.mode)}.`); return; }
1486
+ }
1487
+ ctx.permissions.setMode(real);
1488
+ ok(`Permission mode: ${c.bold(next)}`);
1489
+ if (real === 'bypassPermissions') warn('The agent will not ask before writing files or running commands.');
1490
+ if (next === 'plan') info('The agent can read and think, but nothing runs until you switch out of plan mode.');
1491
+ };
1492
+ // ── rules ──
1493
+ // The mode is a blunt instrument; rules are the fine one. `/permissions rules`
1494
+ // shows what has accumulated, which matters because "always allow" writes to
1495
+ // disk and a list nobody can read is a list nobody can revoke.
1496
+ const next = args[0];
1497
+ if (next === 'rules' || next === 'allow' || next === 'deny' || next === 'forget') {
1498
+ const { parseRule } = await import('./agent/rules.js');
1499
+ if (next === 'rules') {
1500
+ const { allow, deny, session } = ctx.permissions.rules;
1501
+ if (!allow.length && !deny.length && !session.length) {
1502
+ info('No permission rules yet. "Always allow" in a prompt writes one, or use /permissions allow <rule>.');
1503
+ info(`${c.dim('Rules look like')} ${c.bold('run_command(npm test:*)')} ${c.dim('or')} ${c.bold('edit_file(src/**)')}.`);
1504
+ return null;
1505
+ }
1506
+ table([
1507
+ ...deny.map((r) => [` ${c.red('deny')}`, r]),
1508
+ ...allow.map((r) => [` ${c.accent('allow')}`, r]),
1509
+ ...session.map((r) => [` ${c.dim('session')}`, c.dim(r)]),
1510
+ ]);
1511
+ info(`${c.dim('Deny always wins. Saved rules apply to')} ${ctx.flags.cwd}${c.dim('; remove one with /permissions forget <rule>.')}`);
1512
+ return null;
1513
+ }
1514
+ const rule = args.slice(1).join(' ').trim();
1515
+ if (!rule) { warn(`Give a rule: /permissions ${next} run_command(npm test:*)`); return null; }
1516
+ if (next === 'forget') { ctx.permissions.removeRule(rule); ok(`Forgot ${c.bold(rule)}.`); return null; }
1517
+ if (!parseRule(rule)) { warn(`That is not a rule. Try ${c.bold('run_command(npm test:*)')} or ${c.bold('edit_file(src/**)')}.`); return null; }
1518
+ ctx.permissions.addRule(rule, { persist: true, kind: next });
1519
+ ok(`${next === 'deny' ? 'Denied' : 'Allowed'}: ${c.bold(rule)} — saved for this project.`);
1520
+ return null;
1521
+ }
1522
+
1523
+ if (next) {
1524
+ if (!Object.keys(MODES).concat('auto').includes(next)) { warn(`Unknown mode: ${next}. Try a mode, or ${c.bold('/permissions rules')}.`); return null; }
1525
+ await applyMode(next);
1526
+ return null;
1527
+ }
1528
+ if (!ctx.tui) {
1529
+ table([
1530
+ [` ${c.dim('mode')}`, ctx.permissions.mode],
1531
+ [` ${c.dim('modes')}`, 'default · acceptEdits · plan · bypassPermissions (Shift+Tab cycles)'],
1532
+ ]);
1533
+ return null;
1534
+ }
1535
+ const picked = await pickOption(ctx.tui, 'Permission mode', 'How much may the agent do unasked?',
1536
+ Object.entries(MODES).map(([value, hint]) => ({
1537
+ value,
1538
+ label: `${value === ctx.permissions.mode ? '●' : '○'} ${value} ${hint}`.slice(0, 88),
1539
+ })));
1540
+ if (!picked) return null;
1541
+ await applyMode(picked);
1542
+ return null;
1543
+ }
1544
+
1545
+ case '/skills': {
1546
+ const entries = Object.values(ctx.skills || {});
1547
+ if (!entries.length) { info('No skills found. Add .jaitus/skills/<name>/SKILL.md to this project.'); return null; }
1548
+ for (const skill of entries) out(` ${c.brand('/' + skill.name).padEnd(26)}${c.dim(skill.description)}`);
1549
+ return null;
1550
+ }
1551
+
1552
+ case '/sessions': {
1553
+ const sessions = listSessions({ cwd: ctx.flags.cwd });
1554
+ if (!sessions.length) { info('No saved sessions in this directory yet.'); return null; }
1555
+ for (const item of sessions.slice(0, 20)) {
1556
+ const marker = item.id === ctx.sessionState.id ? c.brand('●') : c.grey('○');
1557
+ out(` ${marker} ${item.id.slice(0, 8)} ${c.bold(sessionSummary(item).title)} ${c.dim(item.updatedAt)}`);
1558
+ }
1559
+ info(`Pick one with ${c.bold('/resume')} (no id needed) or ${c.bold('jaitus --resume <id>')}.`);
1560
+ return null;
1561
+ }
1562
+
1563
+ case '/session':
1564
+ table([
1565
+ [` ${c.dim('id')}`, ctx.sessionState.id],
1566
+ [` ${c.dim('created')}`, ctx.sessionState.createdAt],
1567
+ [` ${c.dim('updated')}`, ctx.sessionState.updatedAt],
1568
+ [` ${c.dim('messages')}`, String(ctx.messages.length)],
1569
+ ]);
1570
+ return null;
1571
+
1572
+ case '/rename': {
1573
+ const title = args.join(' ').trim();
1574
+ if (!title) { info(`Current title: ${ctx.sessionState.title || '(untitled)'}`); return null; }
1575
+ ctx.sessionState.title = title.slice(0, 120);
1576
+ saveSession(ctx.sessionState);
1577
+ ok(`Session renamed to ${c.bold(ctx.sessionState.title)}.`);
1578
+ return null;
1579
+ }
1580
+
1581
+ case '/resume': {
1582
+ const applyResume = (target) => {
1583
+ ctx.messages.splice(0, ctx.messages.length, ...target.messages);
1584
+ ctx.sessionState.id = target.id;
1585
+ ctx.sessionState.createdAt = target.createdAt;
1586
+ ctx.sessionState.updatedAt = target.updatedAt;
1587
+ ctx.sessionState.title = target.title;
1588
+ ctx.sessionState.model = target.model || ctx.model;
1589
+ if (target.model) ctx.setModel(target.model);
1590
+ // The resumed conversation's own totals, not the one being left. Mutated in place:
1591
+ // sessionState.spent and every reader hold this same object.
1592
+ const t = target.spent || {};
1593
+ const n = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0);
1594
+ Object.assign(ctx.spent, { input: n(t.input), cached: n(t.cached), output: n(t.output), tools: n(t.tools), turns: n(t.turns) });
1595
+ ctx.resetContext?.();
1596
+ ctx.tui?.setSpent?.(ctx.spent);
1597
+ if (ctx.tui) replaySession(ctx.tui, target.messages);
1598
+ ok(`Resumed ${c.bold(target.id.slice(0, 8))} — ${c.dim(sessionSummary(target).title)}.`);
1599
+ };
1600
+ if (args[0]) {
1601
+ const target = loadSession(args[0]);
1602
+ if (!target) { warn('That session does not exist. /resume with no id lists them.'); return null; }
1603
+ applyResume(target);
1604
+ return null;
1605
+ }
1606
+ // No id: pick from this directory's sessions instead of typing one.
1607
+ if (!ctx.tui) {
1608
+ const all = listSessions({ cwd: ctx.flags.cwd });
1609
+ if (!all.length) { info('No saved sessions in this directory yet.'); return null; }
1610
+ info(`Pick one: ${c.bold('/resume <id>')} — ${all.slice(0, 9).map((s) => s.id.slice(0, 8)).join(' ')}`);
1611
+ return null;
1612
+ }
1613
+ const all = listSessions({ cwd: ctx.flags.cwd }).slice(0, 9);
1614
+ if (!all.length) { info('No saved sessions in this directory yet.'); return null; }
1615
+ const chosen = await pickOption(ctx.tui, 'Resume session',
1616
+ 'Load a saved session from this directory?',
1617
+ all.map((s) => ({
1618
+ value: s.id,
1619
+ label: `${s.id === ctx.sessionState.id ? '● ' : '○ '}${s.id.slice(0, 8)} ${sessionSummary(s).title} ${s.updatedAt || ''}`.slice(0, 90),
1620
+ })),
1621
+ [c.dim('The current conversation is saved first — nothing is lost.')]);
1622
+ if (!chosen) { info('Kept the current session.'); return null; }
1623
+ applyResume(loadSession(chosen));
1624
+ return null;
1625
+ }
1626
+
1627
+ case '/hooks': {
1628
+ const events = configuredHookEvents(ctx.flags.cwd);
1629
+ info(events.length ? `Configured lifecycle hooks: ${events.join(', ')}` : 'No hooks configured. Add .jaitus/hooks.json to this project.');
1630
+ return null;
1631
+ }
1632
+
1633
+ case '/tui': {
1634
+ const want = (args[0] || '').toLowerCase();
1635
+ const { loadSettings: loadS, saveSettings: saveS } = await import('./config.js');
1636
+ if (!want) {
1637
+ const saved = loadS().tui || '(auto)';
1638
+ const active = ctx.tui ? 'fullscreen' : 'classic';
1639
+ info(`Renderer: ${c.bold(active)} · saved: ${c.bold(saved)} · /tui fullscreen | /tui default`);
1640
+ return null;
1641
+ }
1642
+ if (!['fullscreen', 'default'].includes(want)) { warn('Use /tui fullscreen or /tui default.'); return null; }
1643
+ await relaunchRenderer(ctx, want);
1644
+ return null;
1645
+ }
1646
+
1647
+ case '/fork': {
1648
+ const { newSession: mkSession, saveSession: persist } = await import('./sessions.js');
1649
+ const forked = mkSession({ cwd: ctx.flags.cwd, model: ctx.model });
1650
+ forked.messages = JSON.parse(JSON.stringify(ctx.messages));
1651
+ forked.title = `${ctx.sessionState.title || 'Untitled'} (fork)`;
1652
+ persist(forked);
1653
+ ctx.sessionState.id = forked.id;
1654
+ ctx.sessionState.createdAt = forked.createdAt;
1655
+ ctx.sessionState.updatedAt = forked.updatedAt;
1656
+ ctx.sessionState.title = forked.title;
1657
+ ok(`Forked into session ${c.bold(forked.id.slice(0, 8))} — the original is untouched.`);
1658
+ return null;
1659
+ }
1660
+
1661
+ case '/theme': {
1662
+ const { THEMES, reloadTheme } = await import('./ui.js');
1663
+ const { loadSettings: loadS2, saveSettings: saveS2 } = await import('./config.js');
1664
+ const names = Object.keys(THEMES);
1665
+ const applyTheme = (name) => {
1666
+ saveS2({ ...loadS2(), theme: name });
1667
+ reloadTheme();
1668
+ // Rows are rendered once and cached with their colours baked in, so a redraw
1669
+ // alone would repaint the chrome and leave the whole transcript in the old
1670
+ // palette. reskin() drops every cached row so they rebuild in the new one.
1671
+ ctx.tui?.reskin ? ctx.tui.reskin() : ctx.tui?.redraw();
1672
+ ok(`Theme: ${c.bold(name)}. Custom overrides live in ~/.jaitus/theme.json.`);
1673
+ };
1674
+ if (args[0]) {
1675
+ if (!names.includes(args[0])) { warn(`Unknown theme: ${args[0]}. Try ${names.join(', ')}.`); return null; }
1676
+ applyTheme(args[0]);
1677
+ return null;
1678
+ }
1679
+ if (!ctx.tui) { info(`Theme: ${c.bold(loadS2().theme || 'auto')} (${names.join(', ')})`); return null; }
1680
+ const picked = await pickOption(ctx.tui, 'Theme', 'Which theme should the CLI wear?', names.map((n) => ({
1681
+ value: n,
1682
+ label: `${n === (loadS2().theme || 'auto') ? '●' : '○'} ${n}${n === 'auto' ? ' (follows the terminal)' : ''}${n === 'daltonized' ? ' (colorblind-safe)' : ''}`,
1683
+ })));
1684
+ if (!picked) return null;
1685
+ applyTheme(picked);
1686
+ return null;
1687
+ }
1688
+
1689
+ case '/vim': {
1690
+ const { loadSettings: loadSv, saveSettings: saveSv } = await import('./config.js');
1691
+ const on = loadSv().editorMode !== 'vim';
1692
+ saveSv({ ...loadSv(), editorMode: on ? 'vim' : 'emacs' });
1693
+ if (on) ok(`Vim editing on — ${c.bold('Esc')} for normal mode (${c.dim('h j k l w e b · d x · v visual · i insert')}). /vim again for readline keys.`);
1694
+ else ok('Vim editing off — readline keys.');
1695
+ return null;
1696
+ }
1697
+
1698
+ case '/attach': {
1699
+ const { readBackground, listBackground } = await import('./background.js');
1700
+ let id = args[0];
1701
+ if (!id && ctx.tui) {
1702
+ const jobs = listBackground().slice(0, 9);
1703
+ if (!jobs.length) { info('No background jobs — start one with ! <command> &.'); return null; }
1704
+ id = await pickOption(ctx.tui, 'Attach', 'Watch which job live?',
1705
+ jobs.map((j) => ({ value: j.id, label: `${j.running ? '…' : '○'} ${j.id.slice(0, 12)} ${j.command.slice(0, 60)}` })));
1706
+ if (!id) return null;
1707
+ }
1708
+ if (!id) { warn('Give a job id — /tasks lists them.'); return null; }
1709
+ const first = readBackground(id, 120);
1710
+ if (!first) { warn(`No background job ${id}.`); return null; }
1711
+ if (!ctx.tui) { out(first.tail); return null; }
1712
+ const fmt = (j) => [
1713
+ `${j.running ? '… live' : `Finished (exit ${j.exit})`} — ${j.command}`,
1714
+ ...String(j.tail || '(no output yet)').split('\n'),
1715
+ ];
1716
+ const iv = setInterval(() => {
1717
+ import('./background.js').then(({ readBackground: rb }) => {
1718
+ const j = rb(id, 120);
1719
+ if (j) ctx.tui.updatePanel(fmt(j));
1720
+ }).catch(() => {});
1721
+ }, 600);
1722
+ try {
1723
+ await ctx.tui.showPanel(`Job ${first.id}`, fmt(first));
1724
+ } finally {
1725
+ clearInterval(iv);
1726
+ }
1727
+ return null;
1728
+ }
1729
+
1730
+ case '/stop': {
1731
+ const { stopBackground, readBackground, listBackground } = await import('./background.js');
1732
+ let id = args[0];
1733
+ if (!id && ctx.tui) {
1734
+ const running = listBackground().filter((j) => j.running).slice(0, 9);
1735
+ if (!running.length) { info('No running background jobs.'); return null; }
1736
+ id = await pickOption(ctx.tui, 'Stop job', 'Stop which job?',
1737
+ running.map((j) => ({ value: j.id, label: `… ${j.id.slice(0, 12)} ${j.command.slice(0, 60)}` })));
1738
+ if (!id) return null;
1739
+ }
1740
+ if (!id) { warn('Give a job id — /tasks lists them.'); return null; }
1741
+ if (!stopBackground(id)) { warn(`No background job ${id}.`); return null; }
1742
+ const j = readBackground(id, 5);
1743
+ ok(`Stopped ${c.bold(id)}${j && j.exit !== null && j.exit !== 0 ? c.dim(` (exit ${j.exit})`) : ''}.`);
1744
+ return null;
1745
+ }
1746
+
1747
+ case '/debug': {
1748
+ const st = ctx.tui?.state;
1749
+ const { sixelSupported, probeDone } = await import('./terminal.js');
1750
+ table([
1751
+ [` ${c.dim('terminal')}`, `${process.env.TERM || '?'} · ${process.stdout.columns || '?'}x${process.stdout.rows || '?'} · TTY ${String(!!process.stdout.isTTY)} · VT ${probeDone() ? 'probed' : 'unprobed'} · sixel ${sixelSupported() ? 'yes' : 'no'}`],
1752
+ [` ${c.dim('renderer')}`, ctx.tui ? 'fullscreen (alt screen)' : 'classic (scrollback)'],
1753
+ [` ${c.dim('transcript')}`, st ? `${st.transcript.length} lines · offset ${st.transcriptOffset} · ${st.viewingTranscript ? 'in transcript view' : 'live'}` : 'n/a'],
1754
+ // Requesting reporting and RECEIVING reports are different facts, and only the
1755
+ // second means the wheel and drag-select work. A terminal that ignores the
1756
+ // request looks, from in here, exactly like one nobody has touched the mouse in.
1757
+ [` ${c.dim('mouse')}`, (() => {
1758
+ const m = ctx.tui?.mouseReport?.();
1759
+ if (!m) return 'n/a (classic renderer)';
1760
+ if (!m.requested) return 'not requested (disabled in settings, or not a TTY)';
1761
+ return m.reportsSeen
1762
+ ? `working — ${m.reportsSeen} report${m.reportsSeen === 1 ? '' : 's'} received`
1763
+ : 'requested (?1000h ?1002h ?1006h) but NO reports received — this terminal may not support SGR mouse reporting';
1764
+ })()],
1765
+ [` ${c.dim('theme')}`, `${loadSettings().theme || 'auto'} · colors ${process.env.JAITUS_ASCII ? 'ascii' : 'on'}`],
1766
+ [` ${c.dim('input')}`, st ? `${st.input.length} chars · cursor ${st.cursor}${st.vim && st.vim !== 'insert' ? ` · vim ${st.vim}` : ''}${st.selection ? ' · selecting' : ''}` : 'n/a'],
1767
+ [` ${c.dim('env')}`, `NO_FLICKER=${process.env.JAITUS_NO_FLICKER || '0'} SCROLL=${process.env.JAITUS_SCROLL_SPEED || '1'}`],
1768
+ ]);
1769
+ info('Paste this with bug reports — it names the terminal instead of guessing.');
1770
+ return null;
1771
+ }
1772
+
1773
+ case '/scroll-speed': {
1774
+ const { loadSettings: loadS3, saveSettings: saveS3 } = await import('./config.js');
1775
+ const SPEEDS = ['0.25', '0.5', '1', '2', '3', '5', '8', '10'];
1776
+ const applySpeed = (n) => {
1777
+ saveS3({ ...loadS3(), scrollSpeed: n });
1778
+ ok(`Wheel speed ${c.bold(String(n))} saved. Wheel acceleration: ${loadS3().wheelAccel !== false ? 'on' : 'off'} (settings wheelAccel).`);
1779
+ };
1780
+ if (args[0]) {
1781
+ const n = Number(args[0]);
1782
+ if (!Number.isFinite(n) || n < 0.25 || n > 10) { warn('Give a number 0.25–10.'); return null; }
1783
+ applySpeed(n);
1784
+ return null;
1785
+ }
1786
+ if (!ctx.tui) { info(`Wheel speed: ${c.bold(String(loadS3().scrollSpeed ?? process.env.JAITUS_SCROLL_SPEED ?? 1))} (0.25–10, or JAITUS_SCROLL_SPEED)`); return null; }
1787
+ const cur = String(loadS3().scrollSpeed ?? process.env.JAITUS_SCROLL_SPEED ?? 1);
1788
+ const picked = await pickOption(ctx.tui, 'Scroll speed', 'How far should one wheel notch travel?',
1789
+ SPEEDS.map((s) => ({ value: s, label: `${s === cur ? '●' : '○'} ${s}${s === '3' ? ' (vim-like)' : ''}` })));
1790
+ if (!picked) return null;
1791
+ applySpeed(Number(picked));
1792
+ return null;
1793
+ }
1794
+
1795
+ case '/agent': {
1796
+ const prompt = args.join(' ').trim();
1797
+ if (!prompt) { info('Usage: /agent inspect the authentication flow'); return null; }
1798
+ const sideMessages = [{ role: 'user', content: prompt }];
1799
+ const sidePermissions = createPermissions({ mode: 'plan' });
1800
+ const side = await runAgent({
1801
+ client: ctx.client, messages: sideMessages, model: ctx.model, cwd: ctx.flags.cwd,
1802
+ permissions: sidePermissions, extraSystem: `${skillCatalog(ctx.skills)}\n\nYou are a focused Jaitus side-agent. Return a concise finding; never edit files or run commands.`,
1803
+ });
1804
+ info(`Side-agent finished (${side.tools || 0} read/tool calls).`);
1805
+ return null;
1806
+ }
1807
+
1808
+ case '/research':
1809
+ await research(args, ctx);
1810
+ return null;
1811
+
1812
+ case '/ask': {
1813
+ const question = args.join(' ').trim();
1814
+ if (!question) { info('Usage: /ask why does this request fail?'); return null; }
1815
+ ctx.setIntent('ask');
1816
+ ctx.permissions.setMode('plan');
1817
+ ctx.messages.push({ role: 'user', content: question });
1818
+ await ctx.runManaged(ctx.messages);
1819
+ return null;
1820
+ }
1821
+
1822
+ case '/architect': {
1823
+ const task = args.join(' ').trim();
1824
+ if (!task) { info('Usage: /architect design a multi-provider quota service'); return null; }
1825
+ ctx.setIntent('architect');
1826
+ ctx.permissions.setMode('plan');
1827
+ ctx.messages.push({ role: 'user', content: task });
1828
+ await ctx.runManaged(ctx.messages);
1829
+ return null;
1830
+ }
1831
+
1832
+ case '/code':
1833
+ ctx.setIntent('build');
1834
+ ok('Implementation intent — approval policy remains unchanged.');
1835
+ return null;
1836
+
1837
+ case '/explain':
1838
+ await explain(args, ctx);
1839
+ return null;
1840
+
1841
+ case '/test':
1842
+ await testCmd(args, ctx);
1843
+ return null;
1844
+
1845
+ case '/commit':
1846
+ await commitCmd(args, ctx);
1847
+ return null;
1848
+
1849
+ case '/code-review':
1850
+ case '/review':
1851
+ await codeReview(args, ctx);
1852
+ return null;
1853
+
1854
+ case '/security-review':
1855
+ await securityReview(args, ctx);
1856
+ return null;
1857
+
1858
+ case '/simplify':
1859
+ await simplify(args, ctx);
1860
+ return null;
1861
+
1862
+ case '/deep-research': {
1863
+ const rounds = args[0] === '--rounds' ? args : ['--rounds', '5', ...args];
1864
+ await research(rounds, ctx);
1865
+ return null;
1866
+ }
1867
+
1868
+ case '/btw': {
1869
+ // A side question: full tools, zero footprint — the main conversation
1870
+ // never sees it, so asides stop polluting the context (/agent is the
1871
+ // read-only twin for inspection tasks).
1872
+ const question = args.join(' ').trim();
1873
+ if (!question) { info('Usage: /btw <quick question> — answered off the record.'); return null; }
1874
+ const sideMessages = [{ role: 'user', content: question }];
1875
+ await runAgent({
1876
+ client: ctx.client, messages: sideMessages, model: ctx.model, cwd: ctx.flags.cwd,
1877
+ permissions: createPermissions({ mode: ctx.permissions?.mode || 'default' }),
1878
+ extraSystem: `${skillCatalog(ctx.skills)}\n\nYou are answering a quick side question. Be brief; this answer is shown once and never stored.`,
1879
+ });
1880
+ info(c.dim('Off the record — the conversation above is untouched.'));
1881
+ return null;
1882
+ }
1883
+
1884
+ case '/plan': {
1885
+ // Plan mode on demand: the agent reads, proposes, and waits — nothing
1886
+ // runs until you leave plan mode. With a task, it starts proposing now.
1887
+ ctx.permissions.setMode('plan');
1888
+ ok('Plan mode — the agent can read and think, but nothing runs until you switch out of it.');
1889
+ const task = args.join(' ').trim();
1890
+ if (task) {
1891
+ ctx.messages.push({ role: 'user', content: task });
1892
+ await ctx.runManaged(ctx.messages);
1893
+ } else {
1894
+ info(`Give it work: ${c.bold('/plan add auth to the settings page')} — or Shift+Tab back out.`);
1895
+ }
1896
+ return null;
1897
+ }
1898
+
1899
+ case '/branch': {
1900
+ // /fork's twin that says what it is: the original session stays saved
1901
+ // under its id, and /resume walks back to it — a branch, not a copy.
1902
+ const { newSession: mkSession, saveSession: persist } = await import('./sessions.js');
1903
+ const name = args.join(' ').trim().slice(0, 60);
1904
+ const forked = mkSession({ cwd: ctx.flags.cwd, model: ctx.model });
1905
+ forked.messages = JSON.parse(JSON.stringify(ctx.messages));
1906
+ forked.title = name ? `${name} (branch)` : `${ctx.sessionState.title || 'Untitled'} (branch)`;
1907
+ persist(forked);
1908
+ ctx.sessionState.id = forked.id;
1909
+ ctx.sessionState.createdAt = forked.createdAt;
1910
+ ctx.sessionState.updatedAt = forked.updatedAt;
1911
+ ctx.sessionState.title = forked.title;
1912
+ ok(`Branched into session ${c.bold(forked.id.slice(0, 8))} — ${c.bold('/resume')} walks back to the original.`);
1913
+ return null;
1914
+ }
1915
+
1916
+ case '/rewind': {
1917
+ // The Esc+Esc flow, typed: pick a checkpoint (or name a turn number)
1918
+ // and restore messages and files to it.
1919
+ const points = ctx.journal.list().reverse();
1920
+ if (!points.length) { info('Nothing to rewind — send a message first.'); return null; }
1921
+ const applyRewind = (index) => {
1922
+ const outcome = ctx.journal.restore(index, ctx.messages);
1923
+ if (!outcome.ok) { fail(`Rewind failed: ${outcome.error}`); return null; }
1924
+ ctx.sessionState.messages = ctx.messages;
1925
+ saveSession(ctx.sessionState);
1926
+ ok(`Rewound to turn ${index + 1} — ${outcome.messageCount} message${outcome.messageCount === 1 ? '' : 's'} restored`
1927
+ + (outcome.restored.length || outcome.deleted.length
1928
+ ? c.dim(` · ${outcome.restored.length} file${outcome.restored.length === 1 ? '' : 's'} reverted${outcome.deleted.length ? `, ${outcome.deleted.length} removed` : ''}`)
1929
+ : ''));
1930
+ for (const f of [...outcome.restored, ...outcome.deleted].slice(0, 5)) {
1931
+ out(c.dim(` ${displayPath(f)}`));
1932
+ }
1933
+ return null;
1934
+ };
1935
+ const n = Number(args[0]);
1936
+ if (args[0] && Number.isInteger(n) && n >= 1) {
1937
+ const found = points.find((p) => p.index + 1 === n);
1938
+ if (!found) { warn(`No checkpoint for turn ${n} — ${points.length} checkpoint${points.length === 1 ? '' : 's'} available.`); return null; }
1939
+ return applyRewind(found.index);
1940
+ }
1941
+ if (!ctx.tui) {
1942
+ for (const p of points.slice(0, 9)) {
1943
+ out(` Turn ${p.index + 1}: ${(p.prompt || '(no prompt)').slice(0, 60)}${p.files ? c.dim(` · ${p.files} file change${p.files === 1 ? '' : 's'} after`) : ''}`);
1944
+ }
1945
+ info(`Restore one with ${c.bold('/rewind <turn>')}.`);
1946
+ return null;
1947
+ }
1948
+ const options = points.slice(0, 9).map((p) => ({
1949
+ value: p.index,
1950
+ label: `Turn ${p.index + 1}: ${(p.prompt || '(no prompt)').slice(0, 60)}${p.files ? c.dim(` · ${p.files} file change${p.files === 1 ? '' : 's'} after`) : ''}`,
1951
+ }));
1952
+ const answer = await ctx.tui.askDialog(
1953
+ `${c.bold('Rewind to an earlier turn?')} ${c.dim('(restores messages and files)')}`,
1954
+ [...options, { value: null, label: c.dim('Keep current state') }],
1955
+ [c.dim('Files the agent changed after the chosen turn are restored to their earlier contents.')],
1956
+ );
1957
+ const chosen = dialogValue(answer);
1958
+ if (chosen === null || chosen === undefined) { info('Still on the current timeline.'); return null; }
1959
+ return applyRewind(chosen);
1960
+ }
1961
+
1962
+ case '/version':
1963
+ out(` jaitus ${c.bold(`v${VERSION}`)}`);
1964
+ return null;
1965
+
1966
+ case '/feedback': {
1967
+ // No backend involved: the report lands in ~/.jaitus/feedback.log with
1968
+ // the session facts attached, ready to paste into an issue.
1969
+ const text = args.join(' ').trim();
1970
+ if (!text) { info(`Usage: ${c.bold('/feedback')} <what happened> — saved locally with session context.`); return null; }
1971
+ try {
1972
+ const line = `[${new Date().toISOString()}] session=${ctx.sessionState.id} model=${ctx.model} cwd=${ctx.flags.cwd} turns=${spent.turns}\n ${text.replace(/\n/g, '\n ')}\n`;
1973
+ fs.appendFileSync(path.join(HOME, 'feedback.log'), line, 'utf8');
1974
+ ok(`Noted in ${c.bold('~/.jaitus/feedback.log')} — thank you. ${c.dim('/export sends the full conversation with it.')}`);
1975
+ } catch (e) {
1976
+ fail(`Could not save feedback: ${e.message}`);
1977
+ }
1978
+ return null;
1979
+ }
1980
+
1981
+ case '/animations': {
1982
+ const { loadSettings: loadSa, saveSettings: saveSa } = await import('./config.js');
1983
+ const want = (args[0] || '').toLowerCase();
1984
+ if (!want) {
1985
+ const on = loadSa().animations !== false && !process.env.JAITUS_NO_ANIMATION;
1986
+ info(`Animations: ${c.bold(on ? 'on' : 'off')} — shimmer, sweeps, stagger. ${c.bold('/animations on|off')} to change.`);
1987
+ return null;
1988
+ }
1989
+ if (!['on', 'off'].includes(want)) { warn('Use /animations on or /animations off.'); return null; }
1990
+ saveSa({ ...loadSa(), animations: want === 'on' });
1991
+ ok(`Animations ${c.bold(want)}.`);
1992
+ return null;
1993
+ }
1994
+
1995
+ case '/login':
1996
+ await login(['--force']);
1997
+ return null;
1998
+
1999
+ case '/logout': {
2000
+ const { default: logout } = await import('./commands/logout.js');
2001
+ await logout();
2002
+ return 'exit';
2003
+ }
2004
+
2005
+ case '/doctor': {
2006
+ const { default: doctor } = await import('./commands/doctor.js');
2007
+ await doctor([], {});
2008
+ return null;
2009
+ }
2010
+
2011
+ // Shell commands, usable without leaving the session. Output routes
2012
+ // through the transcript like everything else in fullscreen.
2013
+ case '/whoami':
2014
+ await commands.whoami.run([], {});
2015
+ return null;
2016
+
2017
+ case '/usage':
2018
+ await commands.usage.run([], {});
2019
+ return null;
2020
+
2021
+ case '/init':
2022
+ await commands.init.run([], { flags: ctx.flags });
2023
+ return null;
2024
+
2025
+ case '/mcp':
2026
+ await commands.mcp.run(args, { json: false, flags: ctx.flags });
2027
+ return null;
2028
+
2029
+ case '/config':
2030
+ await commands.config.run(args, { json: false });
2031
+ return null;
2032
+
2033
+ case '/update':
2034
+ await commands.update.run([], { json: false });
2035
+ return null;
2036
+
2037
+ case '/banner': {
2038
+ // Session header on demand, codex-style.
2039
+ const modeApproval = ctx.permissions.mode === 'plan' ? 'read-only'
2040
+ : ctx.permissions.mode === 'acceptEdits' ? 'auto-edit'
2041
+ : ctx.permissions.mode === 'bypassPermissions' ? 'off' : 'ask';
2042
+ ctx.tui.print(` ${c.dim(`jaitus v${VERSION}`)}`);
2043
+ ctx.tui.print(` ${c.dim(`model ${ctx.model} · approval ${modeApproval} · ${ctx.flags.cwd}`)}`);
2044
+ return null;
2045
+ }
2046
+
2047
+ case '/clear':
2048
+ ctx.messages.length = 0;
2049
+ // A cleared conversation is also the natural moment to pick up an edited
2050
+ // .jaitusignore, rather than making people restart the session for it.
2051
+ forgetIgnoreCache();
2052
+ ctx.tui?.clearTranscript();
2053
+ ctx.resetContext?.();
2054
+ ok('Conversation cleared.');
2055
+ return null;
2056
+
2057
+ case '/undo': {
2058
+ // A turn is the user message plus everything the assistant added after it.
2059
+ let i = messages.length - 1;
2060
+ while (i >= 0 && !(messages[i].role === 'user' && typeof messages[i].content === 'string')) i--;
2061
+ if (i >= 0) { messages.length = i; ctx.resetContext?.(); ok('Dropped the last exchange.'); }
2062
+ else warn('Nothing to undo.');
2063
+ return null;
2064
+ }
2065
+
2066
+ case '/model': {
2067
+ const available = await client.models();
2068
+ const applyModel = (id) => {
2069
+ ctx.setModel(id);
2070
+ ok(`Model for this session: ${c.bold(id)}`);
2071
+ info(`${c.dim(`jaitus config model ${id}`)} makes it the default.`);
2072
+ };
2073
+ if (args[0]) {
2074
+ if (available.length && !available.includes(args[0])) {
2075
+ warn(`The gateway does not advertise ${args[0]}. It lists: ${available.join(', ')}`);
2076
+ return null;
2077
+ }
2078
+ applyModel(args[0]);
2079
+ return null;
2080
+ }
2081
+ if (!available.length) { warn('The gateway returned no models.'); return null; }
2082
+ if (!ctx.tui) { info(`Model: ${c.bold(ctx.model)}`); return null; }
2083
+ const picked = await pickOption(ctx.tui, 'Model', 'Which model serves this session?',
2084
+ available.slice(0, 9).map((m) => ({ value: m, label: `${m === ctx.model ? '●' : '○'} ${m}` })));
2085
+ if (!picked) return null;
2086
+ applyModel(picked);
2087
+ return null;
2088
+ }
2089
+
2090
+ case '/models': {
2091
+ const list = await client.models();
2092
+ if (!list.length) { warn('The gateway returned no models.'); return null; }
2093
+ list.forEach((m) => out(` ${m === ctx.model ? c.brand('●') : c.grey('○')} ${m}`));
2094
+ return null;
2095
+ }
2096
+
2097
+ case '/cost':
2098
+ table([
2099
+ [' Turns', String(spent.turns)],
2100
+ [' Tool calls', String(spent.tools)],
2101
+ [' Input tokens', spent.input.toLocaleString()],
2102
+ [' Cached input tokens', (spent.cached || 0).toLocaleString()],
2103
+ [' Output tokens', spent.output.toLocaleString()],
2104
+ ]);
2105
+ info(`${c.dim('Billed usage is what your account records — see')} ${c.bold('jaitus usage')}.`);
2106
+ return null;
2107
+
2108
+ case '/cwd':
2109
+ info(flags.cwd);
2110
+ return null;
2111
+
2112
+ default:
2113
+ if (ctx.skills?.[verb.slice(1)]) {
2114
+ const skill = ctx.skills[verb.slice(1)];
2115
+ ctx.messages.push({ role: 'user', content: `${skill.content}\n\nUser request for this workflow: ${args.join(' ') || 'Run the workflow.'}` });
2116
+ await ctx.runManaged(ctx.messages);
2117
+ return null;
2118
+ }
2119
+ warn(`Unknown command: ${verb}. Try /help.`);
2120
+ return null;
2121
+ }
2122
+ }
2123
+
2124
+ // ─── Commands ───────────────────────────────────────────────────────────────────
2125
+
2126
+ // How an account's plan reads in whoami and usage: the tier, and where this period stands.
2127
+ function fmtTokens(n) {
2128
+ const v = Number(n) || 0;
2129
+ if (v >= 1e9) return `${+(v / 1e9).toFixed(1)}B`;
2130
+ if (v >= 1e6) return `${+(v / 1e6).toFixed(1)}M`;
2131
+ if (v >= 1e3) return `${Math.round(v / 1e3)}K`;
2132
+ return String(Math.round(v));
2133
+ }
2134
+ function planRows(u) {
2135
+ const st = u.planStatus || null;
2136
+ if (!u.isPaid) return [['Plan', 'Free'], ['Free prompts', `${u.freeRequestsUsed ?? 0} of ${u.freeRequestLimit ?? '?'} used`]];
2137
+ const rows = [['Plan', st?.name || 'Paid']];
2138
+ if (st?.monthlyTokens) {
2139
+ const resets = st.periodResetsAt ? ` · resets ${new Date(st.periodResetsAt).toLocaleDateString()}` : '';
2140
+ rows.push(['This period', `${fmtTokens(st.periodTokens)} of ${fmtTokens(st.monthlyTokens)} tokens${resets}`]);
2141
+ }
2142
+ if (u.planExpiresAt) rows.push(['Renews by', new Date(u.planExpiresAt).toLocaleDateString()]);
2143
+ return rows;
2144
+ }
2145
+
2146
+ const commands = {
2147
+ login: {
2148
+ summary: 'sign in through your browser',
2149
+ usage: ['jaitus login [--force]'],
2150
+ detail: [
2151
+ 'Opens the Jaitus website; you approve there, and this terminal is signed in',
2152
+ 'over a loopback connection with PKCE. No credential ever travels in a URL.',
2153
+ 'The session is kept in ~/.jaitus/credentials.json and renews itself; signing',
2154
+ 'in again on this machine replaces its previous session.',
2155
+ ],
2156
+ run: (argv) => login(argv),
2157
+ },
2158
+
2159
+ logout: {
2160
+ summary: 'sign this device out',
2161
+ usage: ['jaitus logout'],
2162
+ detail: ['Ends this device\'s session on the server, then removes it from this machine.'],
2163
+ run: async () => logout(),
2164
+ },
2165
+
2166
+ whoami: {
2167
+ summary: 'who you are signed in as',
2168
+ usage: ['jaitus whoami [--json]'],
2169
+ run: async (argv, { json }) => {
2170
+ if (!signedIn()) {
2171
+ info('Not signed in. Run `jaitus login`.');
2172
+ return 1;
2173
+ }
2174
+ const client = requireAuth();
2175
+ const { ok: good, body } = await client.api('/auth/me');
2176
+ if (!good) { fail(body?.message || 'Could not reach your account.'); return 1; }
2177
+ const u = body.user || body.data || {};
2178
+ present(json, u, () => table([
2179
+ ['Account', u.email || '—'],
2180
+ ...planRows(u),
2181
+ ['Gateway', endpoints().baseUrl],
2182
+ ]));
2183
+ return 0;
2184
+ },
2185
+ },
2186
+
2187
+ usage: {
2188
+ summary: 'your usage and remaining free prompts',
2189
+ usage: ['jaitus usage [--json]'],
2190
+ run: async (argv, { json }) => {
2191
+ const client = requireAuth();
2192
+ const [{ ok: good, body }, me] = await Promise.all([client.api('/usage/summary'), client.api('/auth/me').catch(() => ({}))]);
2193
+ if (!good) { fail(body?.message || 'Could not load usage.'); return 1; }
2194
+ const d = body.data || {};
2195
+ const u = me?.body?.user || me?.body?.data || null;
2196
+ present(json, { ...d, planStatus: u?.planStatus || null }, () => table([
2197
+ ...(u ? planRows(u) : []),
2198
+ ['Today', `${d.today?.requests ?? 0} requests`],
2199
+ ['This month', `${d.month?.requests ?? 0} requests`],
2200
+ ['Tokens used', `${(d.totalTokensConsumed ?? 0).toLocaleString()}`],
2201
+ ]));
2202
+ return 0;
2203
+ },
2204
+ },
2205
+
2206
+ models: {
2207
+ summary: 'model ids this gateway accepts',
2208
+ usage: ['jaitus models [--json]'],
2209
+ run: async (argv, { json }) => {
2210
+ const client = requireAuth();
2211
+ const list = await client.models();
2212
+ if (!list.length) { warn('The gateway returned no models.'); return 1; }
2213
+ const current = loadSettings().model;
2214
+ present(json, { models: list, default: current || list[0] }, () => {
2215
+ list.forEach((m) => out(` ${m === current ? c.brand('●') : c.grey('○')} ${m}`));
2216
+ out('');
2217
+ info(`Set one with ${c.bold('jaitus config model <id>')}`);
2218
+ });
2219
+ return 0;
2220
+ },
2221
+ },
2222
+
2223
+ sessions: {
2224
+ summary: 'list saved sessions for this directory',
2225
+ usage: ['jaitus sessions [--json]'],
2226
+ detail: ['Sessions are stored locally under ~/.jaitus/sessions and never sent to the gateway until resumed.'],
2227
+ run: (argv, { json, flags }) => {
2228
+ const data = listSessions({ cwd: flags.cwd }).map(sessionSummary);
2229
+ present(json, { sessions: data }, () => {
2230
+ if (!data.length) { info('No saved sessions in this directory yet.'); return; }
2231
+ data.forEach((item) => out(` ${item.id.slice(0, 8)} ${c.bold(item.title)} ${c.dim(item.updatedAt)}`));
2232
+ });
2233
+ return 0;
2234
+ },
2235
+ },
2236
+
2237
+ mcp: {
2238
+ summary: 'list or configure MCP tool servers',
2239
+ usage: [
2240
+ 'jaitus mcp list',
2241
+ 'jaitus mcp add <name> <command> [args...]',
2242
+ 'jaitus mcp remove <name>',
2243
+ ],
2244
+ detail: ['Local stdio servers are read from .mcp.json and exposed to the agent as permission-gated tools.'],
2245
+ run: (argv, { json, flags }) => {
2246
+ const [action = 'list', name, command, ...args] = argv;
2247
+ if (action === 'list') {
2248
+ const servers = listMcpServers(flags.cwd);
2249
+ present(json, { servers }, () => {
2250
+ if (!servers.length) { info('No MCP servers configured.'); return; }
2251
+ servers.forEach((server) => out(` ${c.brand(server.name)} ${server.transport} ${server.command}`));
2252
+ });
2253
+ return 0;
2254
+ }
2255
+ if (action === 'add' && name && command) {
2256
+ addMcpServer(flags.cwd, name, { type: 'stdio', command, args });
2257
+ ok(`Added MCP server ${c.bold(name)}.`);
2258
+ return 0;
2259
+ }
2260
+ if (action === 'remove' && name) {
2261
+ if (!removeMcpServer(flags.cwd, name)) { warn(`No MCP server named ${name}.`); return 1; }
2262
+ ok(`Removed MCP server ${c.bold(name)}.`);
2263
+ return 0;
2264
+ }
2265
+ fail('Usage: jaitus mcp list | add <name> <command> [args...] | remove <name>');
2266
+ return 1;
2267
+ },
2268
+ },
2269
+
2270
+ config: {
2271
+ summary: 'show or change where the CLI is pointed',
2272
+ usage: [
2273
+ 'jaitus config show everything',
2274
+ 'jaitus config model show one value',
2275
+ 'jaitus config model <id> set it',
2276
+ 'jaitus config theme <name> dark, light, daltonized, plain, auto',
2277
+ 'jaitus config copyOnSelect <on|off> auto-copy mouse selection',
2278
+ ],
2279
+ detail: [
2280
+ 'Settable: model, baseUrl, siteUrl, theme, copyOnSelect, scrollSpeed, wheelAccel, autoScroll, animations, autoCompact, mouse, statusLine,',
2281
+ 'braveSearchKey, tavilyKey, serperKey. Stored in ~/.jaitus/settings.json.',
2282
+ '',
2283
+ 'A search API key is the single biggest improvement available to web_search: the free',
2284
+ 'engines are scraped HTML and their quality varies by network and by day. Brave and',
2285
+ 'Tavily both have free tiers. Without one, Jaitus still queries npm, GitHub, MDN and',
2286
+ 'Stack Overflow directly, which covers most of what a coding agent asks.',
2287
+ ],
2288
+ run: (argv, { json }) => {
2289
+ const [key, value] = argv;
2290
+ const settings = loadSettings();
2291
+ const ONOFF = ['copyOnSelect', 'wheelAccel', 'autoScroll', 'animations', 'autoCompact', 'mouse'];
2292
+ const SECRET = ['braveSearchKey', 'tavilyKey', 'serperKey'];
2293
+ if (!key) {
2294
+ const e = endpoints();
2295
+ const data = {
2296
+ gateway: e.baseUrl,
2297
+ website: e.siteUrl,
2298
+ model: settings.model || null,
2299
+ theme: settings.theme || 'auto',
2300
+ statusLine: settings.statusLine || null,
2301
+ copyOnSelect: settings.copyOnSelect !== false,
2302
+ scrollSpeed: settings.scrollSpeed ?? 1,
2303
+ wheelAccel: settings.wheelAccel !== false,
2304
+ autoScroll: settings.autoScroll !== false,
2305
+ animations: settings.animations !== false,
2306
+ autoCompact: settings.autoCompact !== false,
2307
+ mouse: settings.mouse !== false,
2308
+ // Presence and length only. A key echoed into a transcript is a key that has
2309
+ // been published, and the transcript is on disk.
2310
+ searchApi: SECRET.find((k) => settings[k]) || null,
2311
+ configDir: HOME,
2312
+ };
2313
+ present(json, data, () => table([
2314
+ ['Gateway', data.gateway],
2315
+ ['Website', data.website],
2316
+ ['Model', data.model || 'whatever the gateway lists first'],
2317
+ ['Theme', `${data.theme} (dark, light, daltonized, plain, auto)`],
2318
+ ['Copy on select', data.copyOnSelect ? 'on (drag selects, auto-copies)' : 'off (Ctrl+Shift+C copies)'],
2319
+ ['Scroll speed', String(data.scrollSpeed)],
2320
+ ['Wheel accel', data.wheelAccel ? 'on' : 'off'],
2321
+ ['Auto-scroll', data.autoScroll ? 'on' : 'off'],
2322
+ ['Animations', data.animations ? 'on' : 'off'],
2323
+ ['Auto-compact', data.autoCompact ? 'on (summarises near the context limit)' : 'off'],
2324
+ ['Mouse', data.mouse ? 'on (wheel scrolls, clicks act; shift+drag selects)' : 'off'],
2325
+ ['Search API', data.searchApi ? `${data.searchApi} (set)` : 'none — using free engines + npm/GitHub/MDN/Stack Overflow'],
2326
+ ['Status line', data.statusLine || '(unset — set a command to render one)'],
2327
+ ['Config', data.configDir],
2328
+ ]));
2329
+ return 0;
2330
+ }
2331
+ if (!['model', 'baseUrl', 'siteUrl', 'theme', 'statusLine', ...ONOFF, ...SECRET, 'scrollSpeed'].includes(key)) {
2332
+ fail(`Unknown setting: ${key}. Try model, baseUrl, siteUrl, theme, statusLine, ${ONOFF.join(', ')}, ${SECRET.join(', ')} or scrollSpeed.`);
2333
+ return 1;
2334
+ }
2335
+ if (!value) {
2336
+ // Never print a key back. Whether it is set, and how long it is, answers every
2337
+ // question anyone actually has about it.
2338
+ if (SECRET.includes(key)) { out(settings[key] ? `set (${String(settings[key]).length} characters)` : ''); return 0; }
2339
+ if (key === 'theme') { out(settings.theme || 'auto'); return 0; }
2340
+ if (key === 'statusLine') { out(settings.statusLine || ''); return 0; }
2341
+ if (ONOFF.includes(key)) { out(settings[key] !== false ? 'on' : 'off'); return 0; }
2342
+ if (key === 'scrollSpeed') { out(String(settings.scrollSpeed ?? 1)); return 0; }
2343
+ out(String(settings[key] ?? '')); return 0;
2344
+ }
2345
+ if (key === 'theme') {
2346
+ if (!['dark', 'light', 'daltonized', 'plain', 'auto'].includes(value)) { fail('Use dark, light, daltonized, plain or auto.'); return 1; }
2347
+ settings.theme = value;
2348
+ saveSettings(settings);
2349
+ ok(`theme = ${value}`);
2350
+ return 0;
2351
+ }
2352
+ if (ONOFF.includes(key)) {
2353
+ const on = /^(on|true|1|yes)$/i.test(value);
2354
+ const off = /^(off|false|0|no)$/i.test(value);
2355
+ if (!on && !off) { fail(`Use jaitus config ${key} on|off`); return 1; }
2356
+ settings[key] = on;
2357
+ saveSettings(settings);
2358
+ ok(`${key} = ${on ? 'on' : 'off'}`);
2359
+ return 0;
2360
+ }
2361
+ if (key === 'scrollSpeed') {
2362
+ const n = Number(value);
2363
+ if (!Number.isFinite(n) || n < 0.25 || n > 20) { fail('Use 0.25–20.'); return 1; }
2364
+ settings.scrollSpeed = n;
2365
+ saveSettings(settings);
2366
+ ok(`scrollSpeed = ${n}`);
2367
+ return 0;
2368
+ }
2369
+ settings[key] = value;
2370
+ saveSettings(settings);
2371
+ ok(`${key} = ${value}`);
2372
+ return 0;
2373
+ },
2374
+ },
2375
+
2376
+ init: {
2377
+ summary: `create ${CONTEXT_FILE} and .jaitusignore here`,
2378
+ usage: ['jaitus init [--force]'],
2379
+ detail: [
2380
+ `${CONTEXT_FILE} is sent to the agent with every request in this project.`,
2381
+ '.jaitusignore lists paths it must not list, search, read or write.',
2382
+ 'Both are meant to be committed, so everyone gets the same behaviour.',
2383
+ ],
2384
+ run: (argv, { flags }) => init(argv, { cwd: flags.cwd }),
2385
+ },
2386
+
2387
+ doctor: {
2388
+ summary: 'check everything that can stop this working',
2389
+ usage: ['jaitus doctor [--json]'],
2390
+ detail: [
2391
+ 'Node version, config directory, sign-in, gateway reachability, the account',
2392
+ 'and whether an update is out. Never prints your key — only whether one is set.',
2393
+ ],
2394
+ run: (argv, { json }) => doctor(argv, { json }),
2395
+ },
2396
+
2397
+ update: {
2398
+ summary: 'check whether a newer version is published',
2399
+ usage: ['jaitus update [--json]'],
2400
+ detail: ['Reports; does not install. How this CLI was installed is yours to decide.'],
2401
+ run: (argv, { json }) => update(argv, { json }),
2402
+ },
2403
+
2404
+ completion: {
2405
+ summary: `print a completion script (${SHELLS.join(', ')})`,
2406
+ usage: ['jaitus completion <shell>'],
2407
+ detail: ['Each script carries its own one-line install instruction at the top.'],
2408
+ run: (argv) => completion(argv, {
2409
+ commands: Object.keys(commands),
2410
+ flags: FLAG_NAMES.filter((f) => f.startsWith('--')),
2411
+ }),
2412
+ },
2413
+
2414
+ banner: {
2415
+ summary: 'draw the logo',
2416
+ hidden: true,
2417
+ usage: ['jaitus banner [--image]'],
2418
+ run: async (argv, { flags = {} } = {}) => {
2419
+ const image = flags?.image === true || argv.includes('--image');
2420
+ // Probe sixel support first so --image draws real pixels when possible.
2421
+ try {
2422
+ const { probeAsync } = await import('./terminal.js');
2423
+ await probeAsync({ timeoutMs: 350 });
2424
+ } catch { /* quiet fallback below */ }
2425
+ await banner({ version: VERSION, force: true, image });
2426
+ return 0;
2427
+ },
2428
+ },
2429
+
2430
+ version: {
2431
+ summary: 'print the version',
2432
+ hidden: true,
2433
+ run: () => { out(VERSION); return 0; },
2434
+ },
2435
+
2436
+ help: {
2437
+ summary: 'help for a command',
2438
+ usage: ['jaitus help [command]'],
2439
+ run: async (argv) => {
2440
+ if (!argv[0]) { await showHelp(); return 0; }
2441
+ return commandHelp(argv[0]);
2442
+ },
2443
+ },
2444
+ };
2445
+
2446
+ async function showHelp() {
2447
+ await banner({ version: VERSION });
2448
+ if (!process.stdout.isTTY || process.env.JAITUS_NO_BANNER) out(`\n ${wordmark(VERSION)}\n`);
2449
+ out(helpText());
2450
+ }
2451
+
2452
+ // ─── Entry point ────────────────────────────────────────────────────────────────
2453
+
2454
+ export default async function main(argv) {
2455
+ const { flags, rest, passthrough } = parseArgs(argv);
2456
+
2457
+ if (flags.version) { out(VERSION); return 0; }
2458
+
2459
+ const [first, ...others] = rest;
2460
+ const cmd = commands[first];
2461
+
2462
+ if (flags.help) {
2463
+ if (cmd) return commandHelp(first);
2464
+ await showHelp();
2465
+ return 0;
2466
+ }
2467
+
2468
+ if (cmd) return cmd.run([...others, ...passthrough], { json: flags.json, flags });
2469
+
2470
+ // An unrecognised option is a mistake, not part of the task. Silently folding it into
2471
+ // the prompt is how `jaitus --moddel x "fix this"` ends up asking the model to fix
2472
+ // something while quietly ignoring the flag the user thought they set.
2473
+ if (passthrough.length) {
2474
+ fail(`Unknown option: ${passthrough[0]}`);
2475
+ info(`Run ${c.bold('jaitus --help')} to see the options, or put your task in quotes.`);
2476
+ return 1;
2477
+ }
2478
+
2479
+ if (first && first.startsWith('/')) {
2480
+ fail(`${first} only works inside a session. Run ${c.bold('jaitus')} first.`);
2481
+ return 1;
2482
+ }
2483
+
2484
+ const piped = await readStdin();
2485
+ const prompt = rest.join(' ').trim();
2486
+
2487
+ if (prompt) {
2488
+ const task = piped ? `${prompt}\n\n--- piped input ---\n${piped}` : prompt;
2489
+ return oneShot(task, { flags });
2490
+ }
2491
+ if (piped) return oneShot(piped, { flags });
2492
+
2493
+ if (flags.print) {
2494
+ fail('Print mode needs a prompt: jaitus -p "explain this project"');
2495
+ return 1;
2496
+ }
2497
+
2498
+ if (!process.stdin.isTTY) {
2499
+ err('Nothing to do. Give a task: jaitus "add a test for the parser"');
2500
+ return 1;
2501
+ }
2502
+ return session({ flags });
2503
+ }