ucode-agent 1.62.5 → 1.62.7

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
@@ -27,7 +27,7 @@ corner:
27
27
  add a dark mode toggle that remembers the choice
28
28
 
29
29
 
30
- v1.62.5
30
+ v1.62.7
31
31
  ```
32
32
 
33
33
  A light crosses the wordmark once as it opens, and the three lines under the box
@@ -376,7 +376,7 @@ Everything after the frontmatter is the instruction.
376
376
  | `/clear` | clear the screen, keep the conversation |
377
377
  | `/exit` | save and quit |
378
378
 
379
- `ctrl+b` plan/build · `esc` stops a running turn · `ctrl+d` quits ·
379
+ `ctrl+b` plan/build · click `+ file` (or `ctrl+o`) to add a picture or file · `esc` stops a running turn · `ctrl+d` quits ·
380
380
  `↑ ↓` scroll the conversation, or walk history once you are typing ·
381
381
  `tab` completes a command
382
382
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ucode-agent",
3
- "version": "1.62.5",
3
+ "version": "1.62.7",
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",
@@ -0,0 +1,118 @@
1
+ /**
2
+ * attach.js — the "+ file" button: choosing reference files and turning them
3
+ * into something the model can use.
4
+ *
5
+ * The chooser is the computer's own (Finder on macOS, the Open dialog on
6
+ * Windows, zenity on Linux), so a sketch on the Desktop or a brief in
7
+ * Downloads is two clicks away. Where there is none, the caller falls back to
8
+ * a list of the project's files.
9
+ *
10
+ * Images go to the model as pictures; text files ride on the request as
11
+ * reference, under their own name. Anything else is refused with a reason.
12
+ */
13
+
14
+ import { execFile } from 'node:child_process';
15
+ import { promises as fs } from 'node:fs';
16
+ import path from 'node:path';
17
+
18
+ const IMAGE = { png: 'png', jpg: 'jpeg', jpeg: 'jpeg', gif: 'gif', webp: 'webp' };
19
+
20
+ /** Bigger than this and a picture is refused rather than sent. */
21
+ export const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
22
+
23
+ /** Bigger than this and a text file is refused: it would crowd out the conversation. */
24
+ export const MAX_TEXT_BYTES = 100 * 1024;
25
+
26
+ /** Folders never listed in the fallback picker. */
27
+ const SKIP = new Set(['node_modules', '.git', '.next', '.ucode', 'dist', 'build', '.turbo', '.vercel']);
28
+
29
+ /** The native "choose a file" command for this platform, or null when there is none to try. */
30
+ function chooserFor(platform) {
31
+ if (platform === 'darwin') {
32
+ return ['osascript', ['-e', 'POSIX path of (choose file with prompt "Add a file for ucode")']];
33
+ }
34
+ if (platform === 'win32') {
35
+ const script =
36
+ 'Add-Type -AssemblyName System.Windows.Forms;' +
37
+ '$d = New-Object System.Windows.Forms.OpenFileDialog;' +
38
+ '$d.Title = "Add a file for ucode";' +
39
+ 'if ($d.ShowDialog() -eq "OK") { [Console]::Out.Write($d.FileName) }';
40
+ return ['powershell.exe', ['-NoProfile', '-STA', '-Command', script]];
41
+ }
42
+ return ['zenity', ['--file-selection', '--title=Add a file for ucode']];
43
+ }
44
+
45
+ /**
46
+ * Ask the user for a file with the system's own dialog.
47
+ * Resolves to a path, null when they cancelled, or undefined when there is no dialog here.
48
+ */
49
+ export function chooseFile({ platform = process.platform, run = execFile } = {}) {
50
+ const [cmd, args] = chooserFor(platform);
51
+ return new Promise((resolve) => {
52
+ run(cmd, args, { timeout: 10 * 60_000, windowsHide: false }, (err, stdout) => {
53
+ const picked = String(stdout ?? '').trim();
54
+ if (picked) return resolve(picked);
55
+ // The program could not start (ENOENT, EACCES: a string code) means no
56
+ // dialog here; a non-zero exit from one that ran is a cancel.
57
+ resolve(typeof err?.code === 'string' ? undefined : null);
58
+ });
59
+ });
60
+ }
61
+
62
+ /** Files under `root` for the fallback picker, nearest first, at most `limit`. */
63
+ export async function projectFiles(root, limit = 200) {
64
+ const found = [];
65
+ const queue = [''];
66
+ while (queue.length && found.length < limit) {
67
+ const dir = queue.shift();
68
+ const entries = await fs.readdir(path.join(root, dir), { withFileTypes: true }).catch(() => []);
69
+ for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) {
70
+ const rel = path.join(dir, e.name);
71
+ if (e.isDirectory()) { if (!SKIP.has(e.name) && !e.name.startsWith('.')) queue.push(rel); }
72
+ else if (e.isFile() && found.length < limit) found.push(rel);
73
+ }
74
+ }
75
+ return found;
76
+ }
77
+
78
+ /**
79
+ * Read chosen files into what a request carries.
80
+ * Returns { images: [dataUrl], text: string to append to the request, notes: [line for the user] }.
81
+ */
82
+ export async function loadAttachments(files) {
83
+ const images = [];
84
+ const blocks = [];
85
+ const notes = [];
86
+
87
+ for (const file of files) {
88
+ const name = path.basename(file);
89
+ let buf;
90
+ try {
91
+ buf = await fs.readFile(file);
92
+ } catch {
93
+ notes.push(`could not read ${name} — skipped`);
94
+ continue;
95
+ }
96
+
97
+ const kind = IMAGE[path.extname(name).slice(1).toLowerCase()];
98
+ if (kind) {
99
+ if (buf.length > MAX_IMAGE_BYTES) { notes.push(`${name} is over 10 MB — skipped`); continue; }
100
+ images.push(`data:image/${kind};base64,${buf.toString('base64')}`);
101
+ notes.push(`attached ${name}`);
102
+ continue;
103
+ }
104
+
105
+ if (buf.subarray(0, 8192).includes(0)) {
106
+ notes.push(`${name} is not a picture or a text file — skipped`);
107
+ continue;
108
+ }
109
+ if (buf.length > MAX_TEXT_BYTES) { notes.push(`${name} is over 100 KB of text — skipped`); continue; }
110
+ const text = buf.toString('utf8').trimEnd();
111
+ // A fence longer than any run of backticks inside, so the file cannot close it early.
112
+ const fence = '`'.repeat(Math.max(3, ...(text.match(/`+/g) ?? []).map((run) => run.length + 1)));
113
+ blocks.push(`Reference file ${name}, attached by the user:\n${fence}\n${text}\n${fence}`);
114
+ notes.push(`attached ${name}`);
115
+ }
116
+
117
+ return { images, text: blocks.length ? `\n\n${blocks.join('\n\n')}` : '', notes };
118
+ }
package/src/core/loop.js CHANGED
@@ -52,7 +52,8 @@ import { serversReadySince } from '../tools/shell.js';
52
52
  import { formatDuration } from '../ui/activity.js';
53
53
  import { MAX_FILE_OUTPUT } from '../tools/shared.js';
54
54
  import { openInBrowser } from './opener.js';
55
- import { withScope } from './scope.js';
55
+ import { withScope, isNoise } from './scope.js';
56
+ import { chooseFile, projectFiles, loadAttachments } from './attach.js';
56
57
  import { runDoctor } from './doctor.js';
57
58
  import { JS_LOGIC } from './jslogic.js';
58
59
  import { deploy } from '../tools/deploy.js';
@@ -593,6 +594,11 @@ function systemPrompt({ cwd, skills, mode, check, map, memory }) {
593
594
  '',
594
595
  '## How to work',
595
596
  '',
597
+ 'BUILD ONLY WHEN ASKED. Make or change an app only when the message asks for one.',
598
+ 'A greeting, a question, a single word, or anything unclear gets a short plain',
599
+ 'answer - or one question back about what they would like - never an app. "What',
600
+ 'can you do?" is answered in words; "make a quiz app" is built.',
601
+ '',
596
602
  'AN APP THAT IS AWKWARD TO USE IS NOT FINISHED. Before you call it done, look at',
597
603
  'what you built as someone using it for the first time: is the button inside its',
598
604
  'field or sitting on top of it, is there room to breathe between things, does the',
@@ -1021,6 +1027,7 @@ export class Agent {
1021
1027
  }
1022
1028
  };
1023
1029
  this.ui.onModeChange = () => this.showHeader({ clear: false });
1030
+ this.ui.onAttach = () => { this.attachFile().catch(() => {}); };
1024
1031
  }
1025
1032
 
1026
1033
  for (const problem of this.skills.problems ?? []) {
@@ -1124,6 +1131,15 @@ export class Agent {
1124
1131
  continue;
1125
1132
  }
1126
1133
 
1134
+ // "+" on its own is the "+ file" button typed — the way to it on Windows,
1135
+ // where the terminal sends no clicks. Other symbols alone are not a request:
1136
+ // a live "+" was built into a calculator nobody asked for.
1137
+ if (input === '+' && this.ui.addAttachment) { await this.attachFile(); continue; }
1138
+ if (isNoise(input)) {
1139
+ this.ui.note('Say what you would like to build or ask. To add a picture or file, type + or press ctrl+o.');
1140
+ continue;
1141
+ }
1142
+
1127
1143
  try {
1128
1144
  await this.turn(input);
1129
1145
  } catch (err) {
@@ -1232,12 +1248,18 @@ export class Agent {
1232
1248
  forgetReviews(); // a new request: its apps get a fresh design review
1233
1249
  setRequest(input); // create_app checks this before choosing a starter
1234
1250
  const images = await this.attachImages(input);
1251
+ // Files added with the "+ file" button: pictures go as pictures, text as reference.
1252
+ const added = await loadAttachments(this.ui.takeAttachments?.() ?? []);
1253
+ for (const line of added.notes) this.ui.note(line);
1254
+ images.push(...added.images);
1235
1255
  // The skill goes in ahead of the request, so the request is the last
1236
1256
  // thing the model reads. After it, a page of house rules was what the
1237
1257
  // model answered: asked for a dark mode toggle on its second turn, a live
1238
1258
  // run re-read two files and repeated its first turn's summary instead.
1239
1259
  this.autoLoad(input);
1240
- const request = images.length ? { role: 'user', content: input, images } : { role: 'user', content: input };
1260
+ const request = images.length
1261
+ ? { role: 'user', content: input + added.text, images }
1262
+ : { role: 'user', content: input + added.text };
1241
1263
  this.push(request);
1242
1264
 
1243
1265
  if (!this.session.title || this.session.title === 'Untitled') {
@@ -1251,7 +1273,7 @@ export class Agent {
1251
1273
  // Nothing to look up in an empty folder, so those tools do not go out with
1252
1274
  // the request. Decided per turn: the moment there is code, they are back.
1253
1275
  this.fresh = !hasCode(this.map);
1254
- request.content = withScope(input, { fresh: this.fresh });
1276
+ request.content = withScope(input) + added.text;
1255
1277
  this.wantsWeb = WANTS_WEB.test(input);
1256
1278
  await this.persist();
1257
1279
 
@@ -1784,6 +1806,27 @@ export class Agent {
1784
1806
  if (this.openInBrowser(server.url)) this.ui.note(`Opened ${server.url} in your browser`);
1785
1807
  }
1786
1808
 
1809
+ /**
1810
+ * The "+ file" button: choose a file with the computer's own dialog — or,
1811
+ * where there is none, from the project's files — to go with the next message.
1812
+ */
1813
+ async attachFile() {
1814
+ if (this.attaching) return; // a second click while the dialog is open
1815
+ this.attaching = true;
1816
+ try {
1817
+ let file = await chooseFile();
1818
+ if (file === undefined) {
1819
+ const files = await projectFiles(this.cwd);
1820
+ if (!files.length) { this.ui.note('no files in this folder to add'); return; }
1821
+ const index = await this.ui.pick(files, { hint: 'enter to add · esc to cancel' });
1822
+ file = Number.isInteger(index) ? path.join(this.cwd, files[index]) : null;
1823
+ }
1824
+ if (file) this.ui.addAttachment(path.resolve(this.cwd, file));
1825
+ } finally {
1826
+ this.attaching = false;
1827
+ }
1828
+ }
1829
+
1787
1830
  /** Show a URL or a page in the project in the desktop browser (see opener.js). UCODE_OPEN=0 turns it off. */
1788
1831
  openInBrowser(target) {
1789
1832
  if (!this.full || process.env.UCODE_OPEN === '0') return false;
@@ -3005,7 +3048,7 @@ ${out.content}` });
3005
3048
  }
3006
3049
  this.ui.blank();
3007
3050
  this.ui.write(dim(' /models, /session and /sessions do the same as /model and /resume.'));
3008
- this.ui.write(dim(' ctrl+b swaps plan and build · esc stops a running turn · ctrl+d quits'));
3051
+ this.ui.write(dim(' ctrl+b swaps plan and build · + file (or ctrl+o) adds a picture or file · esc stops a running turn · ctrl+d quits'));
3009
3052
  this.ui.blank();
3010
3053
  }
3011
3054
 
package/src/core/scope.js CHANGED
@@ -13,11 +13,30 @@ export const SCOPE_NOTE =
13
13
  'for - no extra views or tools such as timers, calendars, kanban boards, analytics, stats or an ' +
14
14
  'editor for making your own, unless the request names them.)';
15
15
 
16
- /** A request that may be for a new app. Loose on purpose: the note it adds says "if". */
17
- export const BUILD_ASK =
18
- /\b(?:make|build|create|design|code|write|generate|develop)\b|\bi\s+(?:want|need)\b|\b(?:app|game|website|site|page|tracker|calculator|quiz)\b/i;
16
+ /**
17
+ * A request for something to be built: a making word, then a thing to make.
18
+ *
19
+ * It used to fire on any message in an empty folder, and on a bare "app" or
20
+ * "make". A user who typed "+" in their home folder got the note, and the
21
+ * model took it as a request and built a calculator nobody asked for.
22
+ */
23
+ const MAKE = String.raw`\b(?:make|build|create|design|generate|develop|code)\b`;
24
+ const THING = String.raw`\b(?:apps?|games?|website|site|page|tracker|calculator|quiz|tool|dashboard|list|timer|stopwatch|clock|form|portfolio|planner|converter|board|tic[- ]?tac[- ]?toe|snake|sudoku)\b`;
25
+ const BUILDS = [
26
+ new RegExp(`${MAKE}\\s+(?:me\\s+|us\\s+)?(?:an?|some|my|our)\\s`, 'i'), // make a stopwatch, build me a …
27
+ new RegExp(`${MAKE}[^.?!\\n]{0,60}?${THING}`, 'i'), // make tic tac toe, create the quiz page
28
+ /\bi\s+(?:want|need)\s+(?:a|an)\s+\S+/i, // i want a homework tracker
29
+ ];
30
+ /** Asking, not telling: "what can you make?", "how does it work". */
31
+ const QUESTION = /\?\s*$|^\s*(?:what|how|why|when|where|who|which|can|could|does|do|is|are|should|would)\b/i;
19
32
 
20
- /** The request as the model sees it: one in an empty folder, or shaped like a build, carries the note. */
21
- export function withScope(input, { fresh = false } = {}) {
22
- return fresh || BUILD_ASK.test(input) ? `${input}\n\n${SCOPE_NOTE}` : input;
33
+ /** Does this message ask for something to be built? Never for a question. */
34
+ export const asksToBuild = (input) => !QUESTION.test(input) && BUILDS.some((re) => re.test(input));
35
+
36
+ /** The request as the model sees it: one that asks for an app carries the note. */
37
+ export function withScope(input) {
38
+ return asksToBuild(input) ? `${input}\n\n${SCOPE_NOTE}` : input;
23
39
  }
40
+
41
+ /** A message with no letters or digits in it ("+", "?", "...") — nothing to answer or build. */
42
+ export const isNoise = (input) => !/[\p{L}\p{N}]/u.test(String(input ?? ''));
@@ -67,6 +67,24 @@ export function normaliseFiles(files) {
67
67
  });
68
68
  }
69
69
 
70
+ /**
71
+ * Every file passed to create_app belongs in the app's folder.
72
+ *
73
+ * Traced: a build from a sketch passed "index.html", "style.css" and
74
+ * "main.js" with no folder. They landed beside the app, the folder kept the
75
+ * blank starter, and nothing was checked or handed over. A path already under
76
+ * the folder, absolute, or reaching out with .. is left as it was.
77
+ */
78
+ export function intoFolder(files, folder) {
79
+ const prefix = `${folder}/`;
80
+ return files.map((f) => {
81
+ if (!f || typeof f.path !== 'string') return f;
82
+ const p = path.posix.normalize(f.path.replace(/\\/g, '/'));
83
+ if (path.isAbsolute(p) || p.startsWith('..') || p.startsWith(prefix)) return f;
84
+ return { ...f, path: prefix + p };
85
+ });
86
+ }
87
+
70
88
  /** What each starter is for, so the choice is made on purpose. */
71
89
  export const TEMPLATE_NOTES = {
72
90
  'next-shadcn': 'Next.js, TypeScript, Tailwind and shadcn/ui. For anything with routes, data or many components.',
@@ -431,7 +449,8 @@ export async function createApp({ folder, name, description, template = 'plain-h
431
449
  // The app's own files, written in this same call. Two round trips become
432
450
  // one, and round trips are nearly all of the time a build takes.
433
451
  // Next.js only: paths that have missed src/ are corrected before they land.
434
- const placed = template === 'next-shadcn' ? placeForNext(given, path.basename(target.abs)) : { files: given, moved: [] };
452
+ const inFolder = intoFolder(given, target.show);
453
+ const placed = template === 'next-shadcn' ? placeForNext(inFolder, path.basename(target.abs)) : { files: inFolder, moved: [] };
435
454
  const mine = placed.files;
436
455
  const wrote = mine.length ? await batchWrite({ files: mine }) : null;
437
456
  const written = new Set(mine.map((f) => resolveIn(f.path, 'create_app', 'files').abs));
package/src/ui/screen.js CHANGED
@@ -40,7 +40,7 @@ import {
40
40
  theme, blue, sky, deep, dim, edge, ADDED, REMOVED, BANNER, BANNER_WIDTH, SPINNER,
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
- bannerPaint, RAIL, modeChip, asNarrationLine } from './theme.js';
43
+ bannerPaint, RAIL, modeChip, ADD_CHIP, asNarrationLine } from './theme.js';
44
44
  import { FRAME_MS, spinnerGlyph, formatDuration, doneLine, workingLine, bannerSweep, SWEEP_MS } from './activity.js';
45
45
  import { gitBranch } from '../core/git.js';
46
46
  import { renderer, render } from './markdown.js';
@@ -209,6 +209,12 @@ export class Screen {
209
209
  // the chip is clickable wherever the terminal forwards clicks.
210
210
  this.mode = 'build';
211
211
  this.chipTo = 0;
212
+ // The "+ file" button: its columns on the status row, and the files it has
213
+ // added so far, which go with the next message. Ctrl+O does the same.
214
+ this.plusFrom = 0;
215
+ this.plusTo = 0;
216
+ this.attachments = [];
217
+ this.onAttach = null;
212
218
  this.onInterrupt = null;
213
219
  this.onModeChange = null;
214
220
  this.spinTimer = null;
@@ -802,7 +808,7 @@ export class Screen {
802
808
  ['dir', value(shortenPath(this.facts.cwd ?? this.cwd, room - 10))],
803
809
  branch && ['branch', value(branch)],
804
810
  VERSION && ['version', value(this.facts.update ? `${VERSION} → ${this.facts.update} next start` : VERSION)],
805
- ['keys', value('/help · esc interrupts · ctrl+b plan')],
811
+ ['keys', value('/help · esc interrupts · ctrl+b plan · ctrl+o file')],
806
812
  ].filter(Boolean).slice(0, BANNER.length - 1);
807
813
 
808
814
  while (facts.length < BANNER.length - 1) facts.push(['', '']);
@@ -980,7 +986,7 @@ export class Screen {
980
986
  statusRow(width = this.width()) {
981
987
  const inner = width - 2; // the space between the two borders
982
988
  const chip = this.modeChip();
983
- const left = ` ${chip} ${chalk.white(this.model || '—')}`;
989
+ const left = ` ${chip} ${ADD_CHIP} ${chalk.white(this.model || '—')}`;
984
990
 
985
991
  // How long the turn has taken, back in the box beside the other two facts
986
992
  // about the session. It is not on the live line: that line says what is
@@ -993,9 +999,13 @@ export class Screen {
993
999
 
994
1000
  // Where a click on the bottom row still counts as hitting the mode chip.
995
1001
  this.chipTo = 2 + visLen(chip);
1002
+ this.plusFrom = this.chipTo + 3; // after the two spaces
1003
+ this.plusTo = this.plusFrom + visLen(ADD_CHIP) - 1;
996
1004
 
997
1005
  const between = Math.max(1, inner - visLen(left) - visLen(right));
998
- const middle = this.flashText ? dim(clip(this.flashText, between - 2)) : '';
1006
+ const files = this.attachments.map((f) => path.basename(f)).join(', ');
1007
+ const middle = this.flashText ? dim(clip(this.flashText, between - 2))
1008
+ : files ? sky(clip(`+ ${files}`, between - 2)) : '';
999
1009
 
1000
1010
  const tail = middle ? `${middle} ` : '';
1001
1011
  const pad = Math.max(1, inner - visLen(left) - visLen(tail) - visLen(right));
@@ -1417,11 +1427,29 @@ export class Screen {
1417
1427
  if (this.welcoming()) {
1418
1428
  const g = this.welcomeGeometry();
1419
1429
  const statusRow = g.boxTop + g.inputRows + 3; // 1-based
1420
- if (row === statusRow && col > g.left + 1 && col <= g.left + this.chipTo) this.toggleMode();
1430
+ if (row !== statusRow) return;
1431
+ if (col > g.left + 1 && col <= g.left + this.chipTo) this.toggleMode();
1432
+ else if (col >= g.left + this.plusFrom && col <= g.left + this.plusTo) this.onAttach?.();
1421
1433
  return;
1422
1434
  }
1423
- // The mode chip, at the left of the bottom row.
1424
- if (row === this.rows - 1 && col >= 2 && col <= this.chipTo) this.toggleMode();
1435
+ // The mode chip and the "+ file" button, at the left of the bottom row.
1436
+ if (row !== this.rows - 1) return;
1437
+ if (col >= 2 && col <= this.chipTo) this.toggleMode();
1438
+ else if (col >= this.plusFrom && col <= this.plusTo) this.onAttach?.();
1439
+ }
1440
+
1441
+ /** A file chosen with "+ file", to go with the next message. */
1442
+ addAttachment(file) {
1443
+ if (!this.attachments.includes(file)) this.attachments.push(file);
1444
+ this.render();
1445
+ }
1446
+
1447
+ /** The files for the message being sent; the button starts empty again. */
1448
+ takeAttachments() {
1449
+ const files = this.attachments;
1450
+ this.attachments = [];
1451
+ this.render();
1452
+ return files;
1425
1453
  }
1426
1454
 
1427
1455
  onKey(key) {
@@ -1453,7 +1481,10 @@ export class Screen {
1453
1481
  this.history.unshift(text);
1454
1482
  // Answers to a y/N or a numbered pick are not messages, so they are
1455
1483
  // not echoed: the prompt reports its own outcome.
1456
- if (!this.pendingPrompt) this.userMessage(text);
1484
+ if (!this.pendingPrompt) {
1485
+ this.userMessage(text);
1486
+ for (const f of this.attachments) this.push(dim(` + ${path.basename(f)}`));
1487
+ }
1457
1488
  }
1458
1489
  this.render();
1459
1490
  this.submit(text);
@@ -1481,7 +1512,12 @@ export class Screen {
1481
1512
  this.toggleMode();
1482
1513
  return;
1483
1514
 
1484
- case '\x15': // ctrl+u — clear the line
1515
+ case '\x0f': // ctrl+o — the "+ file" button, for terminals that send no clicks
1516
+ this.onAttach?.();
1517
+ return;
1518
+
1519
+ case '\x15': // ctrl+u — clear the line; on an empty line, the attached files
1520
+ if (!this.buffer) this.attachments = [];
1485
1521
  this.buffer = this.buffer.slice(this.cursor);
1486
1522
  this.cursor = 0;
1487
1523
  break;
package/src/ui/theme.js CHANGED
@@ -127,6 +127,9 @@ export const PLAN_CHIP = chalk.bgHex('#24344f').hex('#8fbcff').bold;
127
127
  export const modeChip = (mode) =>
128
128
  mode === 'plan' ? PLAN_CHIP(' PLAN ') : BUILD_CHIP(' BUILD ');
129
129
 
130
+ /** The "+ file" button beside the mode chip, in the quieter of the two chip styles. */
131
+ export const ADD_CHIP = PLAN_CHIP(' + file ');
132
+
130
133
  /** The spinner. Braille dots, because they animate in place without jitter. */
131
134
  export const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
132
135