ucode-agent 1.64.0 → 1.65.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.
package/README.md CHANGED
@@ -4,6 +4,23 @@ A coding agent that lives in your terminal. It reads your code, edits it, runs
4
4
  your commands, and keeps every conversation on disk. It runs on Google's
5
5
  Gemini models, free with a key.
6
6
 
7
+ ## At a glance
8
+
9
+ | | |
10
+ | --- | --- |
11
+ | **Builds** | a whole app in about a minute, from a starter that already works, opened for you when it does |
12
+ | **Thinks** | a thinking level per step — cheap on easy steps, more on the plan, most when a fix has failed |
13
+ | **Designs** | its own tone, typefaces and accent for every app, and a check that sends the generated look back |
14
+ | **Checks** | types, syntax, related tests, the running server's errors, and the page itself — opened and clicked |
15
+ | **Fixes** | sends problems back to the model, tries a different approach when a fix fails, learns the common ones |
16
+ | **Edits real code** | finds the code first, smallest change in the code's own style; renames by code shape |
17
+ | **Undoes** | `/undo [n]` puts the whole project back, including what commands changed |
18
+ | **Git** | `/diff`, `/commit` with a written message, `/review` for bugs |
19
+ | **Extends** | MCP servers, hooks, skills, your own slash commands |
20
+ | **Restyles** | itself and your terminal, when you ask: "make ucode orange and my terminal navy" |
21
+ | **Automates** | `ucode -p "task" --json` for scripts and CI; `npm run eval` runs ten real jobs |
22
+ | **Stays free** | Gemini's free tier, paced to its per-minute limit — or Ollama, offline |
23
+
7
24
  It opens on a quiet screen — the name, the place to type, and the version in the
8
25
  corner:
9
26
 
@@ -19,7 +36,7 @@ corner:
19
36
  ╭──────────────────────────────────────────────────────────────────────────────╮
20
37
  │ › Ask anything… │
21
38
  │ │
22
- │ BUILD North Mini Code 0% │
39
+ │ BUILD Gemini 3.5 Flash-Lite 0% │
23
40
  ╰──────────────────────────────────────────────────────────────────────────────╯
24
41
 
25
42
  try build me a landing page for a coffee shop
@@ -27,7 +44,7 @@ corner:
27
44
  add a dark mode toggle that remembers the choice
28
45
 
29
46
 
30
- v1.62.7
47
+ v1.65.0
31
48
  ```
32
49
 
33
50
  A light crosses the wordmark once as it opens, and the three lines under the box
@@ -49,7 +66,7 @@ The dashboard is at http://localhost:3000, and `npm run dev` brings it back up.
49
66
  ╭──────────────────────────────────────────────────────────────────────────────────╮
50
67
  │ › now add a dark mode toggle │
51
68
  │ │
52
- │ BUILD North Mini Code 4% │
69
+ │ BUILD Gemini 3.5 Flash-Lite 4% │
53
70
  ╰──────────────────────────────────────────────────────────────────────────────────╯
54
71
  ```
55
72
 
@@ -101,7 +118,7 @@ When Google is overloaded and Flash-Lite stops answering, ucode carries on with
101
118
 
102
119
  ## What it does
103
120
 
104
- **Twenty-one tools.** `create_app`, `read_file`, `read_files`, `write_file`,
121
+ **Twenty-one tools, and any MCP server you add.** `create_app`, `read_file`, `read_files`, `write_file`,
105
122
  `batch_write`, `edit_file`, `multi_edit`, `edit_files`, `rename_symbol`,
106
123
  `find_symbol`, `outline`, `type_of`, `add_block`, `list_dir`, `glob`, `grep`,
107
124
  `run_command`, `run_commands`, `look_at_app`, `web_search`, `deploy`. Read-only
@@ -253,7 +270,7 @@ check and a screenshot; the only way to find out is to press something.
253
270
 
254
271
  It also reports console errors, failed requests, content that spills off a
255
272
  phone screen, broken images and unlabeled controls, saves screenshots to
256
- `.ucode/screenshots`, and has Nemotron Nano Omni review them the way a designer
273
+ `.ucode/screenshots`, and has Gemini review them the way a designer
257
274
  would. The model fixes what it finds before calling the app done. Both widths load at once, and the designer review — the slow part — runs
258
275
  on the first look at an app in each request and is skipped, not waited on, when
259
276
  the vision model is busy. The look after the fixes re-runs only the fast checks:
@@ -372,6 +389,7 @@ Everything after the frontmatter is the instruction.
372
389
  | `/init` | read the project and write its `UCODE.md` |
373
390
  | `/mcp` | connected MCP servers and their tools |
374
391
  | `/permissions [ask\|auto]` | ask before every command, or run them; what is always allowed |
392
+ | `/theme [what]` | change how ucode or your terminal looks — or just ask in words |
375
393
  | `/look [url]` | open the running app and report what is on the page |
376
394
  | `/deploy [folder]` | put the app online and get its link |
377
395
  | `/mic` | say what you want instead of typing it — same as `ctrl+t` |
@@ -399,6 +417,32 @@ Recording uses what the computer already has: Windows' built-in recorder, `sox`
399
417
  or `ffmpeg` on macOS (`brew install sox`), `arecord` or `sox` on Linux. If a
400
418
  quiet mic is taken for silence, set `UCODE_MIC_QUIET` lower than 800.
401
419
 
420
+ ### Change how it looks — and your terminal
421
+
422
+ Just ask: "make ucode orange with the arc spinner", "put my name under the
423
+ logo", "make my terminal navy with a bigger font", "make the terminal a bit
424
+ see-through". Or use `/theme`:
425
+
426
+ ```
427
+ /theme what it looks like now, and the choices
428
+ /theme orange a new colour at once (a name, #ff8c2b, or rgb(...))
429
+ /theme reset ucode's own blue again
430
+ /theme terminal reset the terminal back the way it was
431
+ ```
432
+
433
+ ucode's look is saved in `~/.ucode/theme.json` — accent, spinner (`dots`,
434
+ `line`, `arc`, `circle`, `square`, `bounce`, `pulse`, `star`) and the line
435
+ under the logo — so it survives restarts and updates.
436
+
437
+ The terminal is changed the way each one allows, after you say yes:
438
+
439
+ | Terminal | What changes | How long |
440
+ | --- | --- | --- |
441
+ | Windows Terminal | background, text, cursor, font, size, opacity | kept, every tab (the old settings are backed up) |
442
+ | Terminal.app (macOS) | background, text, cursor, font, size | this window |
443
+ | iTerm2 (macOS) | background, text, cursor | this session |
444
+ | Linux, VS Code and others | background, text, cursor | this session |
445
+
402
446
  ### Your own commands
403
447
 
404
448
  A file `.ucode/commands/explain.md` (or `~/.ucode/commands/` for every
@@ -448,7 +492,7 @@ ucode -p "make a quiz app" --json --yes one JSON line: ok, answer, files, ste
448
492
 
449
493
  Progress goes to stderr. With no `--yes`, anything that would be asked is declined.
450
494
  `npm run eval` runs ten real jobs this way and checks each one — use it before
451
- a release (it spends about 100 free requests).
495
+ a release (it spends about 30 free requests, and takes about seven minutes).
452
496
 
453
497
  ### Other models
454
498
 
@@ -499,7 +543,12 @@ ucode [options]
499
543
 
500
544
  | | |
501
545
  | --- | --- |
502
- | `~/.ucode/.env` | `UCODE_API_KEY`, and `TAVILY_API_KEY` for web search |
546
+ | `~/.ucode/.env` | `GEMINI_API_KEY`, and `TAVILY_API_KEY` for web search |
547
+ | `~/.ucode/settings.json`, `.ucode/settings.json` | permissions, always-allowed commands, hooks |
548
+ | `~/.ucode/mcp.json`, `.ucode/mcp.json` | MCP servers |
549
+ | `.ucode/commands/*.md` | your own slash commands |
550
+ | `~/.ucode/snapshots/` | the project before each turn, for `/undo` |
551
+ | `~/.ucode/lessons.json` | mistakes ucode keeps catching, warned about next time |
503
552
  | `~/.ucode/sessions/` | one JSON per conversation |
504
553
  | `.ucode/skills/` | skills belonging to a project |
505
554
  | `UCODE.md` | project memory, read every turn |
@@ -529,6 +578,13 @@ src/core/window.js folding a long conversation to fit
529
578
  src/core/skills.js loading skills, and deciding which load themselves
530
579
  src/core/context.js the project map and project memory
531
580
  src/core/failure.js one error shape: what, why, what next
581
+ src/core/scope.js what a request carries: build scope, design direction, edit rules
582
+ src/core/genericcheck.js the check for a generated-looking design
583
+ src/core/lessons.js mistakes counted across sessions, warned about up front
584
+ src/core/snapshot.js the project before every turn, for /undo
585
+ src/core/settings.js permissions, always-allow, hooks, project trust
586
+ src/core/mcp.js the MCP client: stdio and HTTP servers, no SDK
587
+ src/core/headless.js ucode -p: one job, no keyboard
532
588
  src/tools/ the twenty-one tools, plus their shared plumbing
533
589
  src/ui/screen.js the full-screen interface
534
590
  src/ui/plain.js the same interface for when there is no terminal
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ucode-agent",
3
- "version": "1.64.0",
3
+ "version": "1.65.0",
4
4
  "description": "ucode - a terminal coding agent that reads, edits and runs your code, on Google Gemini models.",
5
5
  "type": "module",
6
6
  "main": "ucode.js",
package/src/core/loop.js CHANGED
@@ -67,6 +67,8 @@ import { Snapshots } from './snapshot.js';
67
67
  import { loadSettings, addAllow, isTrusted, trust, runHook } from './settings.js';
68
68
  import { McpHub, readServers, USER_MCP, projectMcpFile } from './mcp.js';
69
69
  import { loadCommands, expandCommand } from './commands.js';
70
+ import { SPINNERS, saveLook, look, normaliseColour } from '../ui/theme.js';
71
+ import { customizeTerminal, resetTerminal, detectTerminal, TERMINAL_NAMES } from './terminal.js';
70
72
 
71
73
  /**
72
74
  * Tool calls allowed in one turn.
@@ -569,6 +571,53 @@ export class Writing {
569
571
  /** Tools that only mean anything once there is code in the folder. */
570
572
  const LOOKUP_TOOLS = new Set(['find_symbol', 'outline', 'rename_symbol', 'type_of']);
571
573
 
574
+ /**
575
+ * A request about ucode's own look, or the terminal's - not an app's. Only
576
+ * then do the two tools below go out: "make it purple" about an app must not
577
+ * repaint ucode instead.
578
+ */
579
+ const WANTS_LOOK = /\b(?:ucode|terminal|yourself|your (?:own )?(?:colou?rs?|look|theme|style|banner|spinner|font|byline)|this (?:terminal|window))\b/i;
580
+
581
+ const lookTool = {
582
+ name: 'change_look',
583
+ description:
584
+ 'Change how ucode itself looks in the terminal - only when the user asks about ucode, not about an app. ' +
585
+ 'Sets the accent colour (borders, wordmark, chips; a lighter and a deeper step are worked out from it), ' +
586
+ 'the spinner, and the line under the logo. Saved, so it stays after a restart. reset: true goes back to ucode\'s own blue.',
587
+ parameters: {
588
+ type: 'object',
589
+ properties: {
590
+ accent: { type: 'string', description: 'A hex colour like "#ff8c2b", or a name like "orange", "teal", "pink".' },
591
+ light: { type: 'string', description: 'Optional: the lighter step (labels, the top of the wordmark).' },
592
+ deep: { type: 'string', description: 'Optional: the deeper step (the bottom of the wordmark).' },
593
+ spinner: { type: 'string', enum: Object.keys(SPINNERS), description: 'The spinner shape.' },
594
+ byline: { type: 'string', description: 'The short line beside the logo, up to 40 characters.' },
595
+ reset: { type: 'boolean', description: 'Go back to the default look.' },
596
+ },
597
+ },
598
+ };
599
+
600
+ const terminalTool = {
601
+ name: 'change_terminal',
602
+ description:
603
+ 'Change the terminal window ucode is running in - its background, text colour, cursor, font, font size, or ' +
604
+ 'see-through opacity - only when the user asks about their terminal. Windows Terminal keeps the change; ' +
605
+ 'Terminal.app changes this window; other terminals change colours for this session. The user approves it ' +
606
+ 'first. reset: true puts the terminal back.',
607
+ parameters: {
608
+ type: 'object',
609
+ properties: {
610
+ background: { type: 'string', description: 'Hex like "#1e1e2e" or a name like "navy".' },
611
+ foreground: { type: 'string', description: 'The text colour.' },
612
+ cursor: { type: 'string', description: 'The cursor colour.' },
613
+ font: { type: 'string', description: 'A font installed on this computer, e.g. "Cascadia Code" or "Menlo".' },
614
+ font_size: { type: 'integer', description: 'Font size, 6 to 48.' },
615
+ opacity: { type: 'integer', description: 'How solid the window is, 20 to 100 (Windows Terminal only).' },
616
+ reset: { type: 'boolean', description: 'Put the terminal back as it was.' },
617
+ },
618
+ },
619
+ };
620
+
572
621
  /** A request that plainly wants the internet keeps web_search in a new project. */
573
622
  const WANTS_WEB = /\b(?:search|google|web|online|internet|latest|current|news|docs|documentation|api reference|look up|find out about)\b/i;
574
623
 
@@ -1394,6 +1443,13 @@ export class Agent {
1394
1443
  this.fresh = !hasCode(this.map);
1395
1444
  request.content = withScope(input, { hasCode: !this.fresh }) + added.text;
1396
1445
  this.wantsWeb = WANTS_WEB.test(input);
1446
+ this.wantsLook = WANTS_LOOK.test(input) || this.lookTurn === true;
1447
+ // Offering the tools was not enough: Flash-Lite said "Changing the look"
1448
+ // and stopped. Said on the request itself, it calls them.
1449
+ if (this.wantsLook) {
1450
+ request.content += '\n\n(From ucode: to change how ucode itself looks, call change_look; to change the ' +
1451
+ 'terminal window, call change_terminal. Call them now - do not just say you will - then say in one line what changed.)';
1452
+ }
1397
1453
  await this.persist();
1398
1454
 
1399
1455
  // A busy model was swapped for a fallback earlier; after a few minutes the
@@ -1463,13 +1519,13 @@ export class Agent {
1463
1519
  * wrong turns available to a model deciding what to do next.
1464
1520
  */
1465
1521
  toolsNow() {
1466
- const all = [...tools, loadSkillTool, planTool, delegateTool];
1522
+ const all = [...tools, loadSkillTool, planTool, delegateTool, ...(this.wantsLook ? [lookTool, terminalTool] : [])];
1467
1523
  const live = this.fresh
1468
1524
  ? all.filter((t) => !LOOKUP_TOOLS.has(t.name) && !(t.name === 'web_search' && !this.wantsWeb))
1469
1525
  : all;
1470
1526
  // MCP tools can do anything their server does, so a read-only turn gets none.
1471
1527
  if (this.ui.mode !== 'plan' && !this.readOnly) return [...live, ...(this.mcp?.tools() ?? [])];
1472
- return live.filter((t) => !WRITES.has(t.name));
1528
+ return live.filter((t) => !WRITES.has(t.name) && t !== lookTool && t !== terminalTool);
1473
1529
  }
1474
1530
 
1475
1531
  /**
@@ -2447,6 +2503,8 @@ export class Agent {
2447
2503
  if (call.name === 'load_skill') return this.loadSkill(call.args?.name);
2448
2504
  if (call.name === 'update_plan') return this.updatePlan(call.args?.items);
2449
2505
  if (call.name === 'delegate') return this.delegate(call.args?.tasks);
2506
+ if (call.name === 'change_look') return this.changeLook(call.args ?? {});
2507
+ if (call.name === 'change_terminal') return this.changeTerminal(call.args ?? {});
2450
2508
  if (this.mcp?.has(call.name)) return this.callMcp(call);
2451
2509
 
2452
2510
  // The snapshot /undo goes back to has to be on disk before anything changes.
@@ -2465,6 +2523,57 @@ export class Agent {
2465
2523
  return out;
2466
2524
  }
2467
2525
 
2526
+ /** change_look: ucode's own colours, spinner and byline, saved and shown at once. */
2527
+ changeLook(args) {
2528
+ const changes = {};
2529
+ for (const key of ['accent', 'light', 'deep', 'spinner', 'byline']) {
2530
+ if (args[key] !== undefined) changes[key] = args[key];
2531
+ }
2532
+ if (!args.reset && !Object.keys(changes).length) {
2533
+ throw new ToolFailure({
2534
+ kind: 'bad_args', attempted: 'changing how ucode looks',
2535
+ failed: 'Nothing to change was given.', fix: 'Pass accent, spinner or byline - or reset: true.',
2536
+ });
2537
+ }
2538
+ for (const key of ['accent', 'light', 'deep']) {
2539
+ if (changes[key] && !normaliseColour(changes[key])) {
2540
+ throw new ToolFailure({
2541
+ kind: 'bad_args', attempted: 'changing ucode\'s colour',
2542
+ failed: `"${changes[key]}" is not a colour ucode can read.`, fix: 'Use a hex like "#ff8c2b" or a name like "orange".',
2543
+ });
2544
+ }
2545
+ }
2546
+ const now = saveLook(changes, { reset: Boolean(args.reset) });
2547
+ this.showHeader({ clear: false });
2548
+ this.ui.render?.();
2549
+ return {
2550
+ content: `ucode now looks like this: accent ${now.accent}, spinner ${now.spinner}, byline "${now.byline}". It is saved and already on screen.`,
2551
+ summary: `accent ${now.accent} · ${now.spinner}`,
2552
+ };
2553
+ }
2554
+
2555
+ /** change_terminal: the terminal window itself, after the user says yes. */
2556
+ async changeTerminal(args) {
2557
+ const name = TERMINAL_NAMES[detectTerminal()];
2558
+ if (args.reset) {
2559
+ await confirm(`put ${name} back the way it was`, 'Undoes the changes ucode made to it.', 'write');
2560
+ const done = await resetTerminal();
2561
+ return { content: done.message, summary: done.ok ? 'terminal reset' : 'nothing to reset' };
2562
+ }
2563
+ const wanted = Object.entries(args).filter(([, v]) => v !== undefined && v !== null && v !== '').map(([k, v]) => `${k} ${v}`);
2564
+ await confirm(`change ${name}: ${wanted.join(', ')}`, 'ucode keeps a backup; "/theme terminal reset" puts it back.', 'write');
2565
+ let done;
2566
+ try {
2567
+ done = await customizeTerminal(args);
2568
+ } catch (err) {
2569
+ throw new ToolFailure({ kind: 'bad_args', attempted: `changing ${name}`, failed: err.message, fix: 'Correct the values and try again.' });
2570
+ }
2571
+ if (!done.ok) {
2572
+ throw new ToolFailure({ kind: 'not_supported', attempted: `changing ${name}`, failed: done.message, fix: 'Tell the user what this terminal allows instead.' });
2573
+ }
2574
+ return { content: done.message, summary: 'terminal changed' };
2575
+ }
2576
+
2468
2577
  /** A tool from an MCP server: asked about first, unless always allowed. */
2469
2578
  async callMcp(call) {
2470
2579
  const subject = `mcp:${call.name}`;
@@ -3311,6 +3420,7 @@ export class Agent {
3311
3420
  case '/review': return this.cmdReview();
3312
3421
  case '/mcp': return this.cmdMcp();
3313
3422
  case '/permissions': return this.cmdPermissions(arg);
3423
+ case '/theme': return this.cmdTheme(arg);
3314
3424
  case '/mic':
3315
3425
  if (this.ui.onMic) return this.mic('toggle');
3316
3426
  return this.ui.note('the mic works in the full-screen view — start ucode in a terminal window');
@@ -3426,6 +3536,51 @@ export class Agent {
3426
3536
  this.ui.blank();
3427
3537
  }
3428
3538
 
3539
+ /**
3540
+ * /theme how ucode looks now, and what can change
3541
+ * /theme reset ucode's own blue again
3542
+ * /theme terminal reset the terminal back as it was
3543
+ * /theme orange | #ff8c2b a new accent, at once
3544
+ * /theme <anything else> asked for in words: "/theme make the terminal navy with a bigger font"
3545
+ */
3546
+ async cmdTheme(arg) {
3547
+ const said = arg.trim();
3548
+ if (!said) {
3549
+ this.ui.blank();
3550
+ this.ui.write(` ${blue('accent')} ${look.accent} ${dim('light')} ${look.light} ${dim('deep')} ${look.deep}`);
3551
+ this.ui.write(` ${blue('spinner')} ${look.spinner} ${dim(`(${Object.keys(SPINNERS).join(', ')})`)}`);
3552
+ this.ui.write(` ${blue('byline')} ${look.byline}`);
3553
+ this.ui.write(` ${blue('terminal')} ${TERMINAL_NAMES[detectTerminal()]}`);
3554
+ this.ui.blank();
3555
+ this.ui.write(dim(' /theme orange · /theme reset · /theme terminal reset · or just ask: "make ucode green and my terminal navy"'));
3556
+ this.ui.blank();
3557
+ return;
3558
+ }
3559
+ if (/^reset$/i.test(said)) {
3560
+ saveLook({}, { reset: true });
3561
+ this.showHeader({ clear: false });
3562
+ this.ui.note('ucode is back to its own blue');
3563
+ return;
3564
+ }
3565
+ if (/^terminal\s+reset$/i.test(said)) {
3566
+ const done = await resetTerminal();
3567
+ this.ui.note(done.message);
3568
+ return;
3569
+ }
3570
+ if (/^\S+$/.test(said) && normaliseColour(said)) {
3571
+ saveLook({ accent: said });
3572
+ this.showHeader({ clear: false });
3573
+ this.ui.note(`ucode is ${look.accent} now — saved for next time too`);
3574
+ return;
3575
+ }
3576
+ this.lookTurn = true;
3577
+ try {
3578
+ await this.turn(`Change how ucode or this terminal looks: ${said}`);
3579
+ } finally {
3580
+ this.lookTurn = false;
3581
+ }
3582
+ }
3583
+
3429
3584
  /** /permissions [ask|auto]: whether commands are asked about, and what is always allowed. */
3430
3585
  async cmdPermissions(arg) {
3431
3586
  const want = arg.trim().toLowerCase();
@@ -3518,6 +3673,7 @@ ${out.content}` });
3518
3673
  ['/init', `read the project and write ${MEMORY_FILE} for it`],
3519
3674
  ['/mcp', 'connected MCP servers and their tools'],
3520
3675
  ['/permissions', 'ask before commands, or run them; what is always allowed'],
3676
+ ['/theme [what]', 'change how ucode or your terminal looks — or just ask in words'],
3521
3677
  ['/stats', 'time, steps and tokens this session'],
3522
3678
  ['/doctor', 'check that everything ucode needs is working'],
3523
3679
  ['/look [url]', 'open the running app and report what is on the page'],
@@ -0,0 +1,232 @@
1
+ /**
2
+ * terminal.js — changing the terminal ucode runs in, when the user asks:
3
+ * "make my terminal dark green", "bigger font", "a bit see-through".
4
+ *
5
+ * Each terminal keeps its look somewhere different, so each gets its own way:
6
+ *
7
+ * Windows Terminal its settings.json, which it reloads the moment it is saved.
8
+ * Kept: every new tab looks the same. The file as it was is
9
+ * backed up first, and `reset` puts it back.
10
+ * Terminal.app AppleScript on the window ucode is in: colours, cursor,
11
+ * font and size. That window only; the old values are kept
12
+ * so `reset` can restore them.
13
+ * iTerm2 its escape sequence for colours, this session only.
14
+ * anything else the standard xterm escapes for colours, this session only.
15
+ *
16
+ * Nothing here runs unless the user asked, and the loop asks before it does.
17
+ */
18
+
19
+ import { execFile } from 'node:child_process';
20
+ import { promises as fs, existsSync } from 'node:fs';
21
+ import os from 'node:os';
22
+ import path from 'node:path';
23
+ import { jsonrepair } from 'jsonrepair';
24
+ import { normaliseColour, hexRGB } from '../ui/theme.js';
25
+
26
+ export const BACKUP_DIR = path.join(os.homedir(), '.ucode', 'terminal-backup');
27
+ const backups = () => process.env.UCODE_TERMINAL_BACKUP || BACKUP_DIR;
28
+
29
+ /** Which terminal this is, from what it puts in the environment. */
30
+ export function detectTerminal(env = process.env, platform = process.platform) {
31
+ if (env.WT_SESSION) return 'windows-terminal';
32
+ if (env.TERM_PROGRAM === 'Apple_Terminal') return 'apple-terminal';
33
+ if (env.TERM_PROGRAM === 'iTerm.app' || env.LC_TERMINAL === 'iTerm2') return 'iterm';
34
+ if (env.TERM_PROGRAM === 'vscode') return 'vscode';
35
+ if (platform === 'win32') return 'windows-console';
36
+ return 'other';
37
+ }
38
+
39
+ export const TERMINAL_NAMES = {
40
+ 'windows-terminal': 'Windows Terminal',
41
+ 'apple-terminal': 'Terminal',
42
+ iterm: 'iTerm2',
43
+ vscode: 'the VS Code terminal',
44
+ 'windows-console': 'the Windows console',
45
+ other: 'this terminal',
46
+ };
47
+
48
+ /** Where Windows Terminal keeps its settings: the Store build, Preview, or unpackaged. */
49
+ export function windowsTerminalSettings(env = process.env) {
50
+ const local = env.LOCALAPPDATA ?? path.join(os.homedir(), 'AppData', 'Local');
51
+ const candidates = [
52
+ path.join(local, 'Packages', 'Microsoft.WindowsTerminal_8wekyb3d8bbwe', 'LocalState', 'settings.json'),
53
+ path.join(local, 'Packages', 'Microsoft.WindowsTerminalPreview_8wekyb3d8bbwe', 'LocalState', 'settings.json'),
54
+ path.join(local, 'Microsoft', 'Windows Terminal', 'settings.json'),
55
+ ];
56
+ return candidates.find((f) => existsSync(f)) ?? null;
57
+ }
58
+
59
+ /** What was asked for, cleaned: colours as #rrggbb, a sane size and opacity. */
60
+ export function cleanRequest(req = {}) {
61
+ const out = {};
62
+ for (const key of ['background', 'foreground', 'cursor']) {
63
+ if (req[key] === undefined || req[key] === null || req[key] === '') continue;
64
+ const colour = normaliseColour(req[key]);
65
+ if (!colour) throw new Error(`"${req[key]}" is not a colour ucode can read — use a name like "navy" or a hex like #1e1e2e`);
66
+ out[key] = colour;
67
+ }
68
+ if (req.font_size !== undefined && req.font_size !== null && req.font_size !== '') {
69
+ const size = Number(req.font_size);
70
+ if (!Number.isFinite(size) || size < 6 || size > 48) throw new Error('font_size should be a number from 6 to 48');
71
+ out.font_size = Math.round(size);
72
+ }
73
+ if (req.font) out.font = String(req.font).replace(/["\\\r\n]/g, '').slice(0, 60);
74
+ if (req.opacity !== undefined && req.opacity !== null && req.opacity !== '') {
75
+ const opacity = Number(req.opacity);
76
+ if (!Number.isFinite(opacity) || opacity < 20 || opacity > 100) throw new Error('opacity should be a number from 20 to 100');
77
+ out.opacity = Math.round(opacity);
78
+ }
79
+ return out;
80
+ }
81
+
82
+ /** Windows Terminal: set the look on the defaults, and on any profile that overrides it. */
83
+ export function applyToWindowsTerminal(settings, want) {
84
+ const next = structuredClone(settings);
85
+ if (Array.isArray(next.profiles)) next.profiles = { defaults: {}, list: next.profiles };
86
+ next.profiles ??= {};
87
+ next.profiles.defaults ??= {};
88
+ const set = (profile) => {
89
+ if (want.background) profile.background = want.background;
90
+ if (want.foreground) profile.foreground = want.foreground;
91
+ if (want.cursor) profile.cursorColor = want.cursor;
92
+ if (want.font || want.font_size) {
93
+ profile.font = { ...(profile.font ?? {}), ...(want.font ? { face: want.font } : {}), ...(want.font_size ? { size: want.font_size } : {}) };
94
+ }
95
+ if (want.opacity !== undefined) {
96
+ profile.opacity = want.opacity;
97
+ profile.useAcrylic = want.opacity < 100;
98
+ }
99
+ };
100
+ set(next.profiles.defaults);
101
+ // A profile with its own value would hide the default, so it changes too.
102
+ const keys = ['background', 'foreground', 'cursorColor', 'font', 'opacity'];
103
+ for (const profile of next.profiles.list ?? []) {
104
+ if (keys.some((k) => profile[k] !== undefined)) set(profile);
105
+ }
106
+ return next;
107
+ }
108
+
109
+ const run = (file, args) => new Promise((resolve) => {
110
+ execFile(file, args, { windowsHide: true, timeout: 15_000 }, (err, stdout, stderr) =>
111
+ resolve({ ok: !err, out: String(stdout ?? '').trim(), err: String(stderr || err?.message || '').trim() }));
112
+ });
113
+
114
+ /** AppleScript's colours run 0-65535. */
115
+ const appleColour = (hex) => `{${hexRGB(hex).map((v) => v * 257).join(', ')}}`;
116
+ const TAB = 'selected tab of front window';
117
+
118
+ async function appleTerminal(want) {
119
+ const backup = path.join(backups(), 'apple-terminal.json');
120
+ if (!existsSync(backup)) {
121
+ const get = await run('osascript', ['-e', `tell application "Terminal" to get {background color, normal text color, cursor color, font name, font size} of ${TAB}`]);
122
+ if (get.ok) {
123
+ await fs.mkdir(backups(), { recursive: true });
124
+ await fs.writeFile(backup, JSON.stringify({ raw: get.out }));
125
+ }
126
+ }
127
+ const lines = [];
128
+ if (want.background) lines.push(`set background color of ${TAB} to ${appleColour(want.background)}`);
129
+ if (want.foreground) lines.push(`set normal text color of ${TAB} to ${appleColour(want.foreground)}`);
130
+ if (want.cursor) lines.push(`set cursor color of ${TAB} to ${appleColour(want.cursor)}`);
131
+ if (want.font) lines.push(`set font name of ${TAB} to "${want.font}"`);
132
+ if (want.font_size) lines.push(`set font size of ${TAB} to ${want.font_size}`);
133
+ if (!lines.length) return { ok: false, message: 'Terminal can change colours, the cursor, the font and its size here — not the opacity.' };
134
+ const script = ['tell application "Terminal"', ...lines.map((l) => ` ${l}`), 'end tell'].join('\n');
135
+ const done = await run('osascript', ['-e', script]);
136
+ return done.ok
137
+ ? { ok: true, message: `Changed this Terminal window${want.opacity !== undefined ? ' (opacity is not something Terminal lets ucode set)' : ''}.` }
138
+ : { ok: false, message: `Terminal refused: ${done.err}. macOS may need ucode's terminal allowed under Privacy & Security > Automation.` };
139
+ }
140
+
141
+ /** The escape sequences for colours: iTerm2's own, or the xterm ones nearly everything reads. */
142
+ export function colourEscapes(kind, want) {
143
+ const hex = (h) => h.slice(1);
144
+ if (kind === 'iterm') {
145
+ return [
146
+ want.background && `\x1b]1337;SetColors=bg=${hex(want.background)}\x07`,
147
+ want.foreground && `\x1b]1337;SetColors=fg=${hex(want.foreground)}\x07`,
148
+ want.cursor && `\x1b]1337;SetColors=curbg=${hex(want.cursor)}\x07`,
149
+ ].filter(Boolean).join('');
150
+ }
151
+ return [
152
+ want.foreground && `\x1b]10;${want.foreground}\x07`,
153
+ want.background && `\x1b]11;${want.background}\x07`,
154
+ want.cursor && `\x1b]12;${want.cursor}\x07`,
155
+ ].filter(Boolean).join('');
156
+ }
157
+
158
+ /**
159
+ * Change the terminal. Resolves to { ok, message } and never throws on a
160
+ * terminal that cannot do it; throws only on a request that makes no sense.
161
+ */
162
+ export async function customizeTerminal(request, { env = process.env, platform = process.platform, write = (s) => process.stdout.write(s) } = {}) {
163
+ const want = cleanRequest(request);
164
+ if (!Object.keys(want).length) throw new Error('Say what to change: background, foreground, cursor, font, font_size or opacity.');
165
+ const kind = detectTerminal(env, platform);
166
+
167
+ if (kind === 'windows-terminal') {
168
+ const file = windowsTerminalSettings(env);
169
+ if (!file) return { ok: false, message: 'Could not find Windows Terminal\'s settings.json.' };
170
+ const text = await fs.readFile(file, 'utf8');
171
+ if (!text.trim()) return { ok: false, message: 'Windows Terminal\'s settings.json is empty, so it was left alone.' };
172
+ let settings;
173
+ try {
174
+ settings = JSON.parse(jsonrepair(text.replace(/^/, '')));
175
+ } catch {
176
+ return { ok: false, message: 'Windows Terminal\'s settings.json could not be read, so it was left alone.' };
177
+ }
178
+ await fs.mkdir(backups(), { recursive: true });
179
+ const backup = path.join(backups(), 'windows-terminal.json');
180
+ if (!existsSync(backup)) await fs.writeFile(backup, JSON.stringify({ file, text }));
181
+ await fs.writeFile(file, `${JSON.stringify(applyToWindowsTerminal(settings, want), null, 4)}\n`);
182
+ return { ok: true, message: 'Changed Windows Terminal — every tab, and it stays. "/theme terminal reset" puts it back.' };
183
+ }
184
+
185
+ if (kind === 'apple-terminal') return appleTerminal(want);
186
+
187
+ if (kind === 'windows-console') {
188
+ return { ok: false, message: 'The old Windows console cannot be changed from here. Windows Terminal (free in the Microsoft Store) can.' };
189
+ }
190
+
191
+ const escapes = colourEscapes(kind, want);
192
+ if (!escapes) return { ok: false, message: `${TERMINAL_NAMES[kind]} only lets ucode change its colours, not the font or opacity.` };
193
+ write(escapes);
194
+ const skipped = want.font || want.font_size || want.opacity !== undefined ? ' The font and opacity are not something it lets ucode set.' : '';
195
+ return { ok: true, message: `Changed ${TERMINAL_NAMES[kind]}'s colours for this session.${skipped}` };
196
+ }
197
+
198
+ /** Put the terminal back as it was before ucode changed it. */
199
+ export async function resetTerminal({ env = process.env, platform = process.platform, write = (s) => process.stdout.write(s) } = {}) {
200
+ const kind = detectTerminal(env, platform);
201
+ if (kind === 'windows-terminal') {
202
+ const backup = path.join(backups(), 'windows-terminal.json');
203
+ if (!existsSync(backup)) return { ok: false, message: 'ucode has not changed Windows Terminal, so there is nothing to put back.' };
204
+ const { file, text } = JSON.parse(await fs.readFile(backup, 'utf8'));
205
+ await fs.writeFile(file, text);
206
+ await fs.rm(backup, { force: true });
207
+ return { ok: true, message: 'Windows Terminal is back the way it was.' };
208
+ }
209
+ if (kind === 'apple-terminal') {
210
+ const backup = path.join(backups(), 'apple-terminal.json');
211
+ if (!existsSync(backup)) return { ok: false, message: 'ucode has not changed this Terminal window.' };
212
+ const { raw } = JSON.parse(await fs.readFile(backup, 'utf8'));
213
+ const n = raw.split(/,\s*/);
214
+ // {r,g,b} three times, then the font name and size.
215
+ if (n.length >= 11) {
216
+ const colour = (i) => `{${n.slice(i, i + 3).join(', ')}}`;
217
+ await run('osascript', ['-e', [
218
+ 'tell application "Terminal"',
219
+ ` set background color of ${TAB} to ${colour(0)}`,
220
+ ` set normal text color of ${TAB} to ${colour(3)}`,
221
+ ` set cursor color of ${TAB} to ${colour(6)}`,
222
+ ` set font name of ${TAB} to "${n[9].replace(/"/g, '')}"`,
223
+ ` set font size of ${TAB} to ${Number(n[10]) || 12}`,
224
+ 'end tell',
225
+ ].join('\n')]);
226
+ }
227
+ await fs.rm(backup, { force: true });
228
+ return { ok: true, message: 'This Terminal window is back the way it was.' };
229
+ }
230
+ write(kind === 'iterm' ? '\x1b]1337;SetColors=preset=Default\x07' : '\x1b]110\x07\x1b]111\x07\x1b]112\x07');
231
+ return { ok: true, message: 'The terminal\'s own colours are back.' };
232
+ }
@@ -722,6 +722,10 @@ export function describe(name, args = {}) {
722
722
  return `Searching the web for ${clip(args.query, 60)}`;
723
723
  case 'load_skill':
724
724
  return `Loading the ${clip(args.name, 40)} skill`;
725
+ case 'change_look':
726
+ return args.reset ? 'Putting ucode\'s look back' : 'Changing how ucode looks';
727
+ case 'change_terminal':
728
+ return args.reset ? 'Putting the terminal back' : 'Changing the terminal';
725
729
  default:
726
730
  return `${name} ${clip(JSON.stringify(args), 60)}`;
727
731
  }
@@ -12,7 +12,7 @@
12
12
  */
13
13
 
14
14
  import chalk, { Chalk } from 'chalk';
15
- import { dim, sky, theme, clip, SPINNER, bannerRGB, bannerPaint } from './theme.js';
15
+ import { dim, sky, theme, clip, SPINNER, bannerRGB, bannerPaint, look, hexRGB } from './theme.js';
16
16
 
17
17
  /** One painter per colour level, so a test can ask for truecolour on a pipe. */
18
18
  const painters = new Map();
@@ -289,7 +289,7 @@ export function spinnerGlyph(frame, t, { level = chalk.level } = {}) {
289
289
  const glyph = SPINNER[((frame % SPINNER.length) + SPINNER.length) % SPINNER.length];
290
290
  if (level < 2) return theme.blue(glyph);
291
291
  const k = (Math.sin((Math.max(0, t) / 1000) * Math.PI * 2) + 1) / 2;
292
- const [r, g, b] = mix([0x2f, 0x6f, 0xe0], [0x9f, 0xc6, 0xff], k);
292
+ const [r, g, b] = mix(hexRGB(look.deep), hexRGB(look.light), k);
293
293
  return painter(level).rgb(r, g, b)(glyph);
294
294
  }
295
295
 
package/src/ui/plain.js CHANGED
@@ -14,7 +14,7 @@ import chalk from 'chalk';
14
14
  import {
15
15
  theme, blue, sky, dim, boxTop, boxBottom, boxRow,
16
16
  BANNER, BANNER_WIDTH, SPINNER, clip, shortenPath, asLabel, padVis, visLen, planLine, bannerPaint, modeChip, narrationMark, groupKind,
17
- tidyReply, trimAnswer,
17
+ tidyReply, trimAnswer, look,
18
18
  } from './theme.js';
19
19
  import { formatDuration, doneLine } from './activity.js';
20
20
  import { renderer, render } from './markdown.js';
@@ -101,7 +101,7 @@ export class Plain {
101
101
  ['keys', '/help'],
102
102
  ['', ''],
103
103
  ['', ''],
104
- ['', 'made with ❤ by om dixit'],
104
+ ['', look.byline],
105
105
  ];
106
106
 
107
107
  // There is no input box to hang the status off here, so it goes on the
package/src/ui/screen.js CHANGED
@@ -37,7 +37,7 @@ import { homedir } from 'node:os';
37
37
  import path from 'node:path';
38
38
  import chalk from 'chalk';
39
39
  import {
40
- theme, blue, sky, deep, dim, edge, ADDED, REMOVED, BANNER, BANNER_WIDTH, SPINNER,
40
+ theme, blue, sky, deep, dim, edge, ADDED, REMOVED, BANNER, BANNER_WIDTH, SPINNER, look,
41
41
  boxTop, boxBottom, boxRow, visLen, padVis, clip, wrapAnsi,
42
42
  shortenPath, asLabel, ensureColour, planLine, bare, narration, narrationMark, groupKind, groupLabel, groupTarget, runLine, planRows, tidyReply, trimAnswer,
43
43
  bannerPaint, RAIL, modeChip, ADD_CHIP, micChip, asNarrationLine } from './theme.js';
@@ -819,7 +819,7 @@ export class Screen {
819
819
  ].filter(Boolean).slice(0, BANNER.length - 1);
820
820
 
821
821
  while (facts.length < BANNER.length - 1) facts.push(['', '']);
822
- facts.push(['', 'made with ❤ by om dixit']);
822
+ facts.push(['', look.byline]);
823
823
 
824
824
  const rows = BANNER.map((art, i) => {
825
825
  const [label, text] = facts[i] ?? ['', ''];
package/src/ui/theme.js CHANGED
@@ -9,12 +9,17 @@
9
9
  */
10
10
 
11
11
  import chalk from 'chalk';
12
+ import { readFileSync, writeFileSync, mkdirSync, renameSync } from 'node:fs';
13
+ import os from 'node:os';
14
+ import path from 'node:path';
12
15
 
13
16
  // One hue, three weights. Anything that needs a fourth is asking for emphasis
14
- // it has not earned.
15
- export const blue = chalk.hex('#4d8dff'); // structure: borders, the caret, the wordmark
16
- export const sky = chalk.hex('#8fbcff'); // secondary: labels that still matter
17
- export const deep = chalk.hex('#2f6fe0'); // pressed, quiet, behind
17
+ // it has not earned. The hue is the user's to change ("make ucode orange"):
18
+ // these are `let`, so every module that imported them sees a new look the
19
+ // moment applyLook() runs - see the "Your look" section below.
20
+ export let blue = chalk.hex('#4d8dff'); // structure: borders, the caret, the wordmark
21
+ export let sky = chalk.hex('#8fbcff'); // secondary: labels that still matter
22
+ export let deep = chalk.hex('#2f6fe0'); // pressed, quiet, behind
18
23
  export const dim = chalk.dim;
19
24
 
20
25
  /**
@@ -25,7 +30,7 @@ export const dim = chalk.dim;
25
30
  * terminal fonts visibly thicker, which is enough to separate "where you type"
26
31
  * from "what you are reading" without a second colour.
27
32
  */
28
- export const edge = chalk.hex('#4d8dff').bold;
33
+ export let edge = chalk.hex('#4d8dff').bold;
29
34
 
30
35
  /**
31
36
  * The colour level to use for a stream, or null to leave chalk's guess alone.
@@ -87,8 +92,8 @@ export const BANNER_WIDTH = Math.max(...BANNER.map((r) => r.length));
87
92
  * the page. chalk downshifts the hex to whatever the terminal actually has, so
88
93
  * on a 16-colour terminal this is flat blue again rather than nothing.
89
94
  */
90
- const GRADIENT_TOP = [0x8f, 0xbc, 0xff]; // sky, at the crown
91
- const GRADIENT_BOTTOM = [0x2f, 0x6f, 0xe0]; // deep, in the shadow
95
+ let GRADIENT_TOP = [0x8f, 0xbc, 0xff]; // sky, at the crown
96
+ let GRADIENT_BOTTOM = [0x2f, 0x6f, 0xe0]; // deep, in the shadow
92
97
 
93
98
  export function bannerRGB(row, rows = BANNER.length) {
94
99
  const t = rows > 1 ? Math.min(1, Math.max(0, row / (rows - 1))) : 0;
@@ -121,14 +126,14 @@ export const RAIL = '▌';
121
126
  * once: it is the width of the word, the way a diff's tint is the width of the
122
127
  * line it marks.
123
128
  */
124
- export const BUILD_CHIP = chalk.bgHex('#4d8dff').hex('#0b1220').bold;
125
- export const PLAN_CHIP = chalk.bgHex('#24344f').hex('#8fbcff').bold;
129
+ export let BUILD_CHIP = chalk.bgHex('#4d8dff').hex('#0b1220').bold;
130
+ export let PLAN_CHIP = chalk.bgHex('#24344f').hex('#8fbcff').bold;
126
131
 
127
132
  export const modeChip = (mode) =>
128
133
  mode === 'plan' ? PLAN_CHIP(' PLAN ') : BUILD_CHIP(' BUILD ');
129
134
 
130
135
  /** The "+ file" button beside the mode chip, in the quieter of the two chip styles. */
131
- export const ADD_CHIP = PLAN_CHIP(' + file ');
136
+ export let ADD_CHIP = PLAN_CHIP(' + file ');
132
137
 
133
138
  /** The mic button beside it: quiet when idle, red while it is listening. */
134
139
  const LISTEN_CHIP = chalk.bgHex('#e5484d').hex('#ffffff').bold;
@@ -138,7 +143,7 @@ export const micChip = (state) =>
138
143
  : PLAN_CHIP(' mic ');
139
144
 
140
145
  /** The spinner. Braille dots, because they animate in place without jitter. */
141
- export const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
146
+ export let SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
142
147
 
143
148
  // ---------------------------------------------------------------------------
144
149
  // Boxes
@@ -870,3 +875,127 @@ function withinRoom(text, room = ANSWER_ROOM) {
870
875
  }
871
876
  return (out.trim() || text.slice(0, room)).trim();
872
877
  }
878
+
879
+ // ---------------------------------------------------------------------------
880
+ // Your look
881
+ // ---------------------------------------------------------------------------
882
+
883
+ /**
884
+ * ucode's look is the user's to change, by asking: "make yourself orange",
885
+ * "a calmer spinner", "put my name under the logo". It is a small file of
886
+ * choices rather than an edit to this one, so it survives every update and can
887
+ * never break ucode - a value that does not parse is simply ignored.
888
+ *
889
+ * { "accent": "#ff8c2b", "light": "#ffb454", "deep": "#c2410c",
890
+ * "spinner": "dots", "byline": "made by me" }
891
+ *
892
+ * Only accent is needed; light and deep are worked out from it.
893
+ */
894
+ export const LOOK_FILE = process.env.UCODE_LOOK_FILE || path.join(os.homedir(), '.ucode', 'theme.json');
895
+
896
+ export const SPINNERS = {
897
+ dots: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'],
898
+ line: ['-', '\\', '|', '/'],
899
+ arc: ['◜', '◠', '◝', '◞', '◡', '◟'],
900
+ circle: ['◐', '◓', '◑', '◒'],
901
+ square: ['◰', '◳', '◲', '◱'],
902
+ bounce: ['⠁', '⠂', '⠄', '⠂'],
903
+ pulse: ['·', '•', '●', '•'],
904
+ star: ['✶', '✸', '✹', '✺', '✹', '✸'],
905
+ };
906
+
907
+ export const DEFAULT_LOOK = Object.freeze({
908
+ accent: '#4d8dff', light: '#8fbcff', deep: '#2f6fe0', spinner: 'dots', byline: 'made with ❤ by om dixit',
909
+ });
910
+
911
+ const NAMED = {
912
+ red: '#ef4444', orange: '#f97316', amber: '#f59e0b', gold: '#eab308', yellow: '#facc15', lime: '#84cc16',
913
+ green: '#22c55e', emerald: '#10b981', teal: '#14b8a6', cyan: '#06b6d4', sky: '#0ea5e9', blue: '#4d8dff',
914
+ indigo: '#6366f1', violet: '#8b5cf6', purple: '#a855f7', magenta: '#d946ef', pink: '#ec4899', rose: '#f43f5e',
915
+ white: '#f5f5f5', grey: '#9ca3af', gray: '#9ca3af', silver: '#c0c0c0', black: '#111111', brown: '#a16207',
916
+ coral: '#ff7f50', salmon: '#fa8072', navy: '#1e3a8a', maroon: '#9f1239', olive: '#65a30d', mint: '#6ee7b7',
917
+ lavender: '#c4b5fd', peach: '#fdba74', crimson: '#dc143c', turquoise: '#40e0d0',
918
+ };
919
+
920
+ /** '#abc', '#aabbcc', 'rgb(1,2,3)' or a colour name, as '#rrggbb' - or null. */
921
+ export function normaliseColour(value) {
922
+ const v = String(value ?? '').trim().toLowerCase();
923
+ if (NAMED[v]) return NAMED[v];
924
+ const short = /^#?([0-9a-f])([0-9a-f])([0-9a-f])$/.exec(v);
925
+ if (short) return `#${short[1]}${short[1]}${short[2]}${short[2]}${short[3]}${short[3]}`;
926
+ const long = /^#?([0-9a-f]{6})$/.exec(v);
927
+ if (long) return `#${long[1]}`;
928
+ const rgb = /^rgb\(\s*(\d{1,3})[\s,]+(\d{1,3})[\s,]+(\d{1,3})\s*\)$/.exec(v);
929
+ if (rgb && rgb.slice(1).every((n) => Number(n) <= 255)) {
930
+ return `#${rgb.slice(1).map((n) => Number(n).toString(16).padStart(2, '0')).join('')}`;
931
+ }
932
+ return null;
933
+ }
934
+
935
+ export const hexRGB = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
936
+ const rgbHex = (rgb) => `#${rgb.map((v) => Math.round(v).toString(16).padStart(2, '0')).join('')}`;
937
+
938
+ /** Part of the way from one colour to another: t = 0 is a, 1 is b. */
939
+ export const mixHex = (a, b, t) => rgbHex(hexRGB(a).map((v, i) => v + (hexRGB(b)[i] - v) * t));
940
+
941
+ /** The look in the file, or nothing. Never throws. */
942
+ export function readLook(file = LOOK_FILE) {
943
+ try {
944
+ const data = JSON.parse(readFileSync(file, 'utf8'));
945
+ return data && typeof data === 'object' && !Array.isArray(data) ? data : {};
946
+ } catch {
947
+ return {};
948
+ }
949
+ }
950
+
951
+ /** The current look, filled out: what applyLook settled on. */
952
+ export let look = { ...DEFAULT_LOOK };
953
+
954
+ /** Put a look on screen. Missing or broken values fall back to the default. */
955
+ export function applyLook(wanted = {}) {
956
+ const accent = normaliseColour(wanted.accent) ?? DEFAULT_LOOK.accent;
957
+ const own = accent !== DEFAULT_LOOK.accent;
958
+ const light = normaliseColour(wanted.light) ?? (own ? mixHex(accent, '#ffffff', 0.45) : DEFAULT_LOOK.light);
959
+ const deepHex = normaliseColour(wanted.deep) ?? (own ? mixHex(accent, '#000000', 0.25) : DEFAULT_LOOK.deep);
960
+ const spinner = SPINNERS[wanted.spinner] ? wanted.spinner : DEFAULT_LOOK.spinner;
961
+ const byline = typeof wanted.byline === 'string' ? wanted.byline.replace(/[\x00-\x1f]/g, '').slice(0, 40) : DEFAULT_LOOK.byline;
962
+
963
+ look = { accent, light, deep: deepHex, spinner, byline };
964
+ blue = chalk.hex(accent);
965
+ sky = chalk.hex(light);
966
+ deep = chalk.hex(deepHex);
967
+ edge = chalk.hex(accent).bold;
968
+ theme.blue = blue;
969
+ theme.sky = sky;
970
+ theme.deep = deep;
971
+ GRADIENT_TOP = hexRGB(light);
972
+ GRADIENT_BOTTOM = hexRGB(deepHex);
973
+ BUILD_CHIP = chalk.bgHex(accent).hex('#0b1220').bold;
974
+ PLAN_CHIP = chalk.bgHex(mixHex(deepHex, '#0b1220', 0.65)).hex(light).bold;
975
+ ADD_CHIP = PLAN_CHIP(' + file ');
976
+ SPINNER = SPINNERS[spinner];
977
+ return look;
978
+ }
979
+
980
+ /**
981
+ * Change the look and keep it: `changes` are merged into what is saved, a key
982
+ * set to null goes back to its default, and { reset: true } starts over.
983
+ */
984
+ export function saveLook(changes = {}, { reset = false, file = LOOK_FILE } = {}) {
985
+ const merged = reset ? {} : { ...readLook(file) };
986
+ for (const [key, value] of Object.entries(changes)) {
987
+ if (!(key in DEFAULT_LOOK)) continue;
988
+ if (value === null || value === '') delete merged[key];
989
+ else merged[key] = value;
990
+ }
991
+ // An accent alone gets its own light and dark steps, not the old ones.
992
+ if ('accent' in changes && !('light' in changes)) delete merged.light;
993
+ if ('accent' in changes && !('deep' in changes)) delete merged.deep;
994
+ mkdirSync(path.dirname(file), { recursive: true });
995
+ const temp = `${file}.${process.pid}.tmp`;
996
+ writeFileSync(temp, `${JSON.stringify(merged, null, 2)}\n`);
997
+ renameSync(temp, file);
998
+ return applyLook(merged);
999
+ }
1000
+
1001
+ applyLook(readLook());