ucode-agent 1.5.0 → 1.7.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 +399 -327
- package/package.json +6 -1
- package/skills/ui-ux/SKILL.md +2 -2
- package/src/core/doctor.js +122 -0
- package/src/core/livelog.js +113 -0
- package/src/core/loop.js +2105 -1659
- package/src/core/provider.js +93 -10
- package/src/core/stuck.js +269 -0
- package/src/core/tests.js +86 -0
- package/src/tools/blocks.js +117 -0
- package/src/tools/browser.js +121 -59
- package/src/tools/cache.js +105 -0
- package/src/tools/deploy.js +283 -0
- package/src/tools/files.js +91 -8
- package/src/tools/index.js +634 -495
- package/src/tools/rename.js +157 -0
- package/src/tools/scaffold.js +85 -6
- package/src/tools/shell.js +799 -701
- package/src/tools/symbols.js +218 -0
- package/src/tools/types.js +179 -0
- package/src/ui/activity.js +203 -0
- package/src/ui/plain.js +22 -3
- package/src/ui/screen.js +65 -19
- package/src/ui/theme.js +5 -1
- package/templates/blocks/app-shell.tsx +81 -0
- package/templates/blocks/data-table.tsx +117 -0
- package/templates/blocks/empty-state.tsx +41 -0
- package/templates/blocks/page-header.tsx +27 -0
- package/templates/blocks/stat-cards.tsx +46 -0
- package/templates/next-shadcn/TEMPLATE.md +53 -9
- package/templates/next-shadcn/_package-lock.json +1335 -148
- package/templates/next-shadcn/components.json +1 -1
- package/templates/next-shadcn/next.config.ts +2 -1
- package/templates/next-shadcn/package.json +4 -2
- package/templates/next-shadcn/presets/citrus.json +77 -0
- package/templates/next-shadcn/presets/graphite.json +77 -0
- package/templates/next-shadcn/presets/grove.json +77 -0
- package/templates/next-shadcn/presets/ocean.json +78 -0
- package/templates/next-shadcn/presets/sunset.json +77 -0
- package/templates/next-shadcn/presets/violet.json +77 -0
- package/templates/next-shadcn/src/components/ui/accordion.tsx +80 -0
- package/templates/next-shadcn/src/components/ui/alert-dialog.tsx +34 -22
- package/templates/next-shadcn/src/components/ui/avatar.tsx +7 -4
- package/templates/next-shadcn/src/components/ui/badge.tsx +15 -18
- package/templates/next-shadcn/src/components/ui/button.tsx +12 -3
- package/templates/next-shadcn/src/components/ui/calendar.tsx +1 -0
- package/templates/next-shadcn/src/components/ui/checkbox.tsx +6 -2
- package/templates/next-shadcn/src/components/ui/collapsible.tsx +33 -0
- package/templates/next-shadcn/src/components/ui/command.tsx +1 -2
- package/templates/next-shadcn/src/components/ui/dialog.tsx +34 -26
- package/templates/next-shadcn/src/components/ui/dropdown-menu.tsx +115 -114
- package/templates/next-shadcn/src/components/ui/hover-card.tsx +43 -0
- package/templates/next-shadcn/src/components/ui/input-group.tsx +2 -4
- package/templates/next-shadcn/src/components/ui/input.tsx +1 -2
- package/templates/next-shadcn/src/components/ui/label.tsx +6 -2
- package/templates/next-shadcn/src/components/ui/popover.tsx +27 -28
- package/templates/next-shadcn/src/components/ui/progress.tsx +11 -63
- package/templates/next-shadcn/src/components/ui/radio-group.tsx +43 -0
- package/templates/next-shadcn/src/components/ui/scroll-area.tsx +6 -6
- package/templates/next-shadcn/src/components/ui/select.tsx +55 -64
- package/templates/next-shadcn/src/components/ui/separator.tsx +6 -3
- package/templates/next-shadcn/src/components/ui/sheet.tsx +35 -26
- package/templates/next-shadcn/src/components/ui/slider.tsx +58 -0
- package/templates/next-shadcn/src/components/ui/switch.tsx +3 -2
- package/templates/next-shadcn/src/components/ui/table.tsx +115 -0
- package/templates/next-shadcn/src/components/ui/tabs.tsx +16 -8
- package/templates/next-shadcn/src/components/ui/toggle-group.tsx +89 -0
- package/templates/next-shadcn/src/components/ui/toggle.tsx +46 -0
- package/templates/next-shadcn/src/components/ui/tooltip.tsx +24 -33
- package/templates/next-shadcn/src/lib/utils.ts +6 -1
- package/ucode.js +8 -1
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* symbols.js — a map of what this codebase declares, so "where is the tip
|
|
3
|
+
* calculated" is one lookup rather than five greps.
|
|
4
|
+
*
|
|
5
|
+
* grep finds every line that mentions a name; almost all of them are uses,
|
|
6
|
+
* and the one that matters is the declaration. This reads each source file
|
|
7
|
+
* once and records only the declarations: functions, classes, components,
|
|
8
|
+
* types, and the route a Next.js page answers on.
|
|
9
|
+
*
|
|
10
|
+
* The scan is by pattern, not by a parser. A parser would be exact but would
|
|
11
|
+
* mean carrying TypeScript itself and paying its start-up on every lookup;
|
|
12
|
+
* declarations are one of the few things regular expressions read reliably,
|
|
13
|
+
* because they sit at the start of a line in formatted code. What this can
|
|
14
|
+
* miss is a declaration written unusually — and a miss costs a grep, which
|
|
15
|
+
* is where we started.
|
|
16
|
+
*
|
|
17
|
+
* The index is held per directory and rebuilt when a file's modified time
|
|
18
|
+
* moves, so an edit is picked up without a rescan of everything.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { promises as fs } from 'node:fs';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import { resolveIn, guard, result, walk } from './shared.js';
|
|
24
|
+
|
|
25
|
+
/** Files worth reading for declarations. */
|
|
26
|
+
const SOURCE = /\.(?:[cm]?[jt]sx?|py)$/i;
|
|
27
|
+
|
|
28
|
+
/** How much of a file to read; a declaration past this is a generated file's. */
|
|
29
|
+
const MAX_BYTES = 400_000;
|
|
30
|
+
|
|
31
|
+
const PATTERNS = [
|
|
32
|
+
// export function foo(...) / export default function foo(...)
|
|
33
|
+
{ kind: 'function', re: /^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s*\*?\s*([A-Za-z_$][\w$]*)/ },
|
|
34
|
+
// class Foo / export class Foo
|
|
35
|
+
{ kind: 'class', re: /^\s*(?:export\s+)?(?:default\s+)?(?:abstract\s+)?class\s+([A-Za-z_$][\w$]*)/ },
|
|
36
|
+
// const foo = (...) => / const foo = async (...) => / const foo = function
|
|
37
|
+
{ kind: 'function', re: /^\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::[^=]+)?=\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*(?::[^=]+)?=>/ },
|
|
38
|
+
{ kind: 'function', re: /^\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s+)?function/ },
|
|
39
|
+
// type Foo = / interface Foo / enum Foo
|
|
40
|
+
{ kind: 'type', re: /^\s*(?:export\s+)?type\s+([A-Za-z_$][\w$]*)\s*[<=]/ },
|
|
41
|
+
{ kind: 'type', re: /^\s*(?:export\s+)?interface\s+([A-Za-z_$][\w$]*)/ },
|
|
42
|
+
{ kind: 'type', re: /^\s*(?:export\s+)?(?:const\s+)?enum\s+([A-Za-z_$][\w$]*)/ },
|
|
43
|
+
// Python
|
|
44
|
+
{ kind: 'function', re: /^\s*(?:async\s+)?def\s+([A-Za-z_][\w]*)/ },
|
|
45
|
+
{ kind: 'class', re: /^\s*class\s+([A-Za-z_][\w]*)/ },
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
/** A capitalised function in a .tsx file is a component, and worth saying so. */
|
|
49
|
+
const isComponent = (name, rel) => /^[A-Z]/.test(name) && /\.[jt]sx$/i.test(rel);
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The URL a Next.js app-router file answers on: src/app/blog/[slug]/page.tsx
|
|
53
|
+
* is /blog/[slug]. Route groups in brackets-as-parens are not part of the path.
|
|
54
|
+
*/
|
|
55
|
+
export function routeFor(rel) {
|
|
56
|
+
const m = rel.replace(/\\/g, '/').match(/(?:^|\/)app\/(.*)\/(page|route|layout)\.[jt]sx?$/i);
|
|
57
|
+
if (!m) {
|
|
58
|
+
if (/(?:^|\/)app\/(page|route|layout)\.[jt]sx?$/i.test(rel.replace(/\\/g, '/'))) return '/';
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
const url = m[1].split('/').filter((s) => s && !/^\(.*\)$/.test(s)).join('/');
|
|
62
|
+
return `/${url}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Every declaration in one file's text, with the line it sits on. */
|
|
66
|
+
export function declarationsIn(rel, text) {
|
|
67
|
+
const found = [];
|
|
68
|
+
const lines = text.split('\n');
|
|
69
|
+
for (let i = 0; i < lines.length; i++) {
|
|
70
|
+
const line = lines[i];
|
|
71
|
+
if (line.length > 400) continue;
|
|
72
|
+
for (const { kind, re } of PATTERNS) {
|
|
73
|
+
const m = line.match(re);
|
|
74
|
+
if (!m) continue;
|
|
75
|
+
const name = m[1];
|
|
76
|
+
found.push({
|
|
77
|
+
name,
|
|
78
|
+
kind: isComponent(name, rel) && kind === 'function' ? 'component' : kind,
|
|
79
|
+
file: rel,
|
|
80
|
+
line: i + 1,
|
|
81
|
+
text: line.trim().slice(0, 160),
|
|
82
|
+
});
|
|
83
|
+
break; // one declaration to a line
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return found;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const indexes = new Map(); // root -> { files: Map<rel, {at, symbols}> }
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Read the declarations of every source file under `root`, reusing what was
|
|
93
|
+
* read before for files whose modified time has not moved.
|
|
94
|
+
*/
|
|
95
|
+
export async function buildIndex(root) {
|
|
96
|
+
let index = indexes.get(root);
|
|
97
|
+
if (!index) { index = { files: new Map() }; indexes.set(root, index); }
|
|
98
|
+
|
|
99
|
+
const rels = (await walk(root, {})).filter((rel) => SOURCE.test(rel));
|
|
100
|
+
const seen = new Set();
|
|
101
|
+
|
|
102
|
+
await Promise.all(rels.map(async (rel) => {
|
|
103
|
+
seen.add(rel);
|
|
104
|
+
const abs = path.join(root, rel);
|
|
105
|
+
let stat;
|
|
106
|
+
try { stat = await fs.stat(abs); } catch { return; }
|
|
107
|
+
if (stat.size > MAX_BYTES) return;
|
|
108
|
+
const had = index.files.get(rel);
|
|
109
|
+
if (had && had.at === stat.mtimeMs) return; // unchanged since last time
|
|
110
|
+
const text = await fs.readFile(abs, 'utf8').catch(() => null);
|
|
111
|
+
if (text === null) return;
|
|
112
|
+
index.files.set(rel, { at: stat.mtimeMs, symbols: declarationsIn(rel, text) });
|
|
113
|
+
}));
|
|
114
|
+
|
|
115
|
+
for (const rel of [...index.files.keys()]) if (!seen.has(rel)) index.files.delete(rel);
|
|
116
|
+
|
|
117
|
+
const symbols = [];
|
|
118
|
+
for (const { symbols: s } of index.files.values()) symbols.push(...s);
|
|
119
|
+
return { symbols, fileCount: index.files.size };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Forget what was read, so the next lookup starts clean. */
|
|
123
|
+
export function clearIndex() { indexes.clear(); }
|
|
124
|
+
|
|
125
|
+
const KINDS = new Set(['function', 'class', 'type', 'component']);
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Where a name is declared. An exact match wins; failing that, anything
|
|
129
|
+
* containing it, so a half-remembered name still lands.
|
|
130
|
+
*/
|
|
131
|
+
export async function findSymbol({ name, kind, path: p = '.' }) {
|
|
132
|
+
const wanted = String(name ?? '').trim();
|
|
133
|
+
if (!wanted) {
|
|
134
|
+
return result('Pass the name of a function, component, class or type to look for.', 'nothing to look for');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const target = resolveIn(p || '.', 'find_symbol', 'path');
|
|
138
|
+
await guard(target, `read ${target.abs}`);
|
|
139
|
+
|
|
140
|
+
const { symbols, fileCount } = await buildIndex(target.abs);
|
|
141
|
+
const pool = kind && KINDS.has(kind) ? symbols.filter((s) => s.kind === kind) : symbols;
|
|
142
|
+
|
|
143
|
+
const lower = wanted.toLowerCase();
|
|
144
|
+
let hits = pool.filter((s) => s.name === wanted);
|
|
145
|
+
let how = 'exactly';
|
|
146
|
+
if (!hits.length) {
|
|
147
|
+
hits = pool.filter((s) => s.name.toLowerCase() === lower);
|
|
148
|
+
how = 'ignoring case';
|
|
149
|
+
}
|
|
150
|
+
if (!hits.length) {
|
|
151
|
+
hits = pool.filter((s) => s.name.toLowerCase().includes(lower));
|
|
152
|
+
how = 'containing';
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (!hits.length) {
|
|
156
|
+
return result(
|
|
157
|
+
`Nothing declared as "${wanted}" in ${target.show} (read ${fileCount} source files).\n` +
|
|
158
|
+
'It may be imported from a package, spelled differently, or built at runtime — grep will find uses.',
|
|
159
|
+
'not declared here'
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
hits.sort((a, b) => a.name.length - b.name.length || a.file.localeCompare(b.file));
|
|
164
|
+
const shown = hits.slice(0, 25);
|
|
165
|
+
const lines = shown.map((s) => {
|
|
166
|
+
const route = routeFor(s.file);
|
|
167
|
+
return `${s.file}:${s.line} ${s.kind} ${s.name}${route ? ` [route ${route}]` : ''}\n ${s.text}`;
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const more = hits.length > shown.length ? `\n[${hits.length - shown.length} more]` : '';
|
|
171
|
+
return result(
|
|
172
|
+
`Declared ${how} "${wanted}":\n\n${lines.join('\n')}${more}`,
|
|
173
|
+
`${hits.length} declaration${hits.length === 1 ? '' : 's'}`
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* What a file declares, and what a folder's files declare — the shape of the
|
|
179
|
+
* code without reading all of it.
|
|
180
|
+
*/
|
|
181
|
+
export async function outline({ path: p = '.' }) {
|
|
182
|
+
const target = resolveIn(p || '.', 'outline', 'path');
|
|
183
|
+
await guard(target, `read ${target.abs}`);
|
|
184
|
+
|
|
185
|
+
const stat = await fs.stat(target.abs).catch(() => null);
|
|
186
|
+
if (stat?.isFile()) {
|
|
187
|
+
const text = await fs.readFile(target.abs, 'utf8').catch(() => '');
|
|
188
|
+
const found = declarationsIn(target.show, text);
|
|
189
|
+
if (!found.length) return result(`${target.show} declares nothing this can see.`, 'nothing declared');
|
|
190
|
+
return result(
|
|
191
|
+
`${target.show}\n` + found.map((s) => ` ${s.line}: ${s.kind} ${s.name}`).join('\n'),
|
|
192
|
+
`${found.length} declaration${found.length === 1 ? '' : 's'}`
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const { symbols, fileCount } = await buildIndex(target.abs);
|
|
197
|
+
if (!symbols.length) return result(`No declarations found under ${target.show}.`, 'nothing declared');
|
|
198
|
+
|
|
199
|
+
const byFile = new Map();
|
|
200
|
+
for (const s of symbols) {
|
|
201
|
+
if (!byFile.has(s.file)) byFile.set(s.file, []);
|
|
202
|
+
byFile.get(s.file).push(s);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const files = [...byFile.keys()].sort();
|
|
206
|
+
const shown = files.slice(0, 60);
|
|
207
|
+
const body = shown.map((f) => {
|
|
208
|
+
const route = routeFor(f);
|
|
209
|
+
const names = byFile.get(f).map((s) => s.name).slice(0, 12).join(', ');
|
|
210
|
+
return `${f}${route ? ` [route ${route}]` : ''}\n ${names}`;
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
const more = files.length > shown.length ? `\n[${files.length - shown.length} more files]` : '';
|
|
214
|
+
return result(
|
|
215
|
+
`${symbols.length} declarations across ${fileCount} files in ${target.show}:\n\n${body.join('\n')}${more}`,
|
|
216
|
+
`${symbols.length} declarations`
|
|
217
|
+
);
|
|
218
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* types.js — asking the app's own TypeScript what something actually is.
|
|
3
|
+
*
|
|
4
|
+
* The model guesses at APIs. It writes `user.fullName` because that is what
|
|
5
|
+
* the property ought to be called, and finds out from a build forty seconds
|
|
6
|
+
* later that it is `displayName`. Every editor solves this by asking a
|
|
7
|
+
* language service, and the answer is already installed: the project's own
|
|
8
|
+
* TypeScript, the same version and the same tsconfig that its build uses.
|
|
9
|
+
*
|
|
10
|
+
* So this loads the project's typescript — never one of ours, which would
|
|
11
|
+
* answer for a different version of the language — and keeps a language
|
|
12
|
+
* service open on it. Asking is then a few milliseconds, and the answer is
|
|
13
|
+
* the one the build will give.
|
|
14
|
+
*
|
|
15
|
+
* A project without TypeScript installed gets a plain sentence saying so.
|
|
16
|
+
* Nothing is installed to make this work.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { promises as fs } from 'node:fs';
|
|
20
|
+
import { statSync, readFileSync, existsSync } from 'node:fs';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import { pathToFileURL } from 'node:url';
|
|
23
|
+
import { resolveIn, guard, result } from './shared.js';
|
|
24
|
+
|
|
25
|
+
/** The nearest folder above `from` holding both a tsconfig and a typescript. */
|
|
26
|
+
export function projectRootFor(from, root) {
|
|
27
|
+
let dir = path.resolve(from);
|
|
28
|
+
const stop = path.resolve(root);
|
|
29
|
+
for (;;) {
|
|
30
|
+
if (existsSync(path.join(dir, 'tsconfig.json'))) return dir;
|
|
31
|
+
const up = path.dirname(dir);
|
|
32
|
+
if (up === dir || !dir.startsWith(stop)) return null;
|
|
33
|
+
dir = up;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The project's own typescript, or null if it has none. */
|
|
38
|
+
async function loadTypeScript(projectDir) {
|
|
39
|
+
let dir = projectDir;
|
|
40
|
+
for (;;) {
|
|
41
|
+
const entry = path.join(dir, 'node_modules', 'typescript', 'lib', 'typescript.js');
|
|
42
|
+
if (existsSync(entry)) {
|
|
43
|
+
const mod = await import(pathToFileURL(entry).href);
|
|
44
|
+
return mod.default ?? mod;
|
|
45
|
+
}
|
|
46
|
+
const up = path.dirname(dir);
|
|
47
|
+
if (up === dir) return null;
|
|
48
|
+
dir = up;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const services = new Map(); // project dir -> { ts, service, versions }
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A language service for this project, made once and kept. Files are read
|
|
56
|
+
* from disk and their version is their modified time, so an edit made by the
|
|
57
|
+
* model is seen on the next question without rebuilding anything.
|
|
58
|
+
*/
|
|
59
|
+
async function serviceFor(projectDir) {
|
|
60
|
+
const had = services.get(projectDir);
|
|
61
|
+
if (had) return had;
|
|
62
|
+
|
|
63
|
+
const ts = await loadTypeScript(projectDir);
|
|
64
|
+
if (!ts) return null;
|
|
65
|
+
|
|
66
|
+
const configPath = path.join(projectDir, 'tsconfig.json');
|
|
67
|
+
const read = ts.readConfigFile(configPath, ts.sys.readFile);
|
|
68
|
+
const parsed = ts.parseJsonConfigFileContent(read.config ?? {}, ts.sys, projectDir);
|
|
69
|
+
|
|
70
|
+
const versions = new Map();
|
|
71
|
+
const versionOf = (file) => {
|
|
72
|
+
try { return String(statSync(file).mtimeMs); } catch { return '0'; }
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const host = {
|
|
76
|
+
getScriptFileNames: () => parsed.fileNames,
|
|
77
|
+
getScriptVersion: (file) => {
|
|
78
|
+
const now = versionOf(file);
|
|
79
|
+
versions.set(file, now);
|
|
80
|
+
return now;
|
|
81
|
+
},
|
|
82
|
+
getScriptSnapshot: (file) => {
|
|
83
|
+
try { return ts.ScriptSnapshot.fromString(readFileSync(file, 'utf8')); } catch { return undefined; }
|
|
84
|
+
},
|
|
85
|
+
getCurrentDirectory: () => projectDir,
|
|
86
|
+
getCompilationSettings: () => parsed.options,
|
|
87
|
+
getDefaultLibFileName: (options) => ts.getDefaultLibFilePath(options),
|
|
88
|
+
fileExists: ts.sys.fileExists,
|
|
89
|
+
readFile: ts.sys.readFile,
|
|
90
|
+
readDirectory: ts.sys.readDirectory,
|
|
91
|
+
directoryExists: ts.sys.directoryExists,
|
|
92
|
+
getDirectories: ts.sys.getDirectories,
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const made = { ts, service: ts.createLanguageService(host, ts.createDocumentRegistry()), versions };
|
|
96
|
+
services.set(projectDir, made);
|
|
97
|
+
return made;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Forget the open services, so the next question starts fresh. */
|
|
101
|
+
export function clearServices() { services.clear(); }
|
|
102
|
+
|
|
103
|
+
/** Character offset of `symbol` used as a name, preferring a given line. */
|
|
104
|
+
export function offsetOf(text, symbol, line) {
|
|
105
|
+
const re = new RegExp(`(?<![\\w$])${symbol.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')}(?![\\w$])`, 'g');
|
|
106
|
+
const hits = [...text.matchAll(re)].map((m) => m.index);
|
|
107
|
+
if (!hits.length) return -1;
|
|
108
|
+
if (!line) return hits[0];
|
|
109
|
+
const lineStart = text.split('\n').slice(0, line - 1).join('\n').length;
|
|
110
|
+
// The occurrence nearest the line asked about.
|
|
111
|
+
return hits.reduce((best, at) =>
|
|
112
|
+
Math.abs(at - lineStart) < Math.abs(best - lineStart) ? at : best, hits[0]);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* What TypeScript says a name is: its exact type or signature, the docs
|
|
117
|
+
* written on it, and where it is defined.
|
|
118
|
+
*/
|
|
119
|
+
export async function typeOf({ path: p, symbol, line }) {
|
|
120
|
+
const wanted = String(symbol ?? '').trim();
|
|
121
|
+
if (!wanted) return result('Pass the name of something in the file to ask about.', 'nothing to ask about');
|
|
122
|
+
|
|
123
|
+
const target = resolveIn(p, 'type_of', 'path');
|
|
124
|
+
await guard(target, `read ${target.abs}`);
|
|
125
|
+
|
|
126
|
+
const text = await fs.readFile(target.abs, 'utf8').catch(() => null);
|
|
127
|
+
if (text === null) return result(`Could not read ${target.show}.`, 'unreadable');
|
|
128
|
+
|
|
129
|
+
const projectDir = projectRootFor(path.dirname(target.abs), path.parse(target.abs).root);
|
|
130
|
+
if (!projectDir) {
|
|
131
|
+
return result(
|
|
132
|
+
`${target.show} is not inside a TypeScript project (no tsconfig.json above it), so there is ` +
|
|
133
|
+
'no type information to give. Read the file, or the package it comes from.',
|
|
134
|
+
'not a typescript project'
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const made = await serviceFor(projectDir);
|
|
139
|
+
if (!made) {
|
|
140
|
+
return result(
|
|
141
|
+
`This project has no TypeScript installed, so nothing can answer what ${wanted} is. ` +
|
|
142
|
+
'Once its packages are installed, ask again.',
|
|
143
|
+
'typescript not installed'
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const { ts, service } = made;
|
|
148
|
+
const at = offsetOf(text, wanted, line);
|
|
149
|
+
if (at < 0) {
|
|
150
|
+
return result(`"${wanted}" does not appear in ${target.show}.`, 'not in this file');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const info = service.getQuickInfoAtPosition(target.abs, at);
|
|
154
|
+
if (!info) {
|
|
155
|
+
return result(
|
|
156
|
+
`TypeScript has nothing to say about ${wanted} in ${target.show} — usually that means the file ` +
|
|
157
|
+
'has an error above this point, or the project has not been installed.',
|
|
158
|
+
'no type information'
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const signature = ts.displayPartsToString(info.displayParts);
|
|
163
|
+
const docs = ts.displayPartsToString(info.documentation ?? []);
|
|
164
|
+
|
|
165
|
+
let where = '';
|
|
166
|
+
const defs = service.getDefinitionAtPosition(target.abs, at) ?? [];
|
|
167
|
+
if (defs.length) {
|
|
168
|
+
const d = defs[0];
|
|
169
|
+
const rel = path.relative(projectDir, d.fileName).replace(/\\/g, '/');
|
|
170
|
+
const body = readFileSync(d.fileName, 'utf8').slice(0, d.textSpan.start);
|
|
171
|
+
where = `\n\nDefined in ${rel}:${body.split('\n').length}`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return result(
|
|
175
|
+
`${signature}${docs ? `\n\n${docs}` : ''}${where}\n\n` +
|
|
176
|
+
'This is from the project\'s own TypeScript, so it is what the build will say.',
|
|
177
|
+
signature.split('\n')[0].slice(0, 80)
|
|
178
|
+
);
|
|
179
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* activity.js — what the status row shows while ucode is working.
|
|
3
|
+
*
|
|
4
|
+
* A long turn is minutes of the agent doing things the user did not type and
|
|
5
|
+
* cannot see coming. The status row is the one place that says it is still
|
|
6
|
+
* going, so it has to look alive at a glance without asking to be read: a
|
|
7
|
+
* spinner that turns, a soft band of light passing across the label, the
|
|
8
|
+
* step count ticking up, and the time the turn has taken so far.
|
|
9
|
+
*
|
|
10
|
+
* Everything here is a pure function of the text and the clock, so it can be
|
|
11
|
+
* tested without a terminal and painted at any frame rate.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import chalk, { Chalk } from 'chalk';
|
|
15
|
+
import { dim, sky, theme, clip, SPINNER } from './theme.js';
|
|
16
|
+
|
|
17
|
+
/** One painter per colour level, so a test can ask for truecolour on a pipe. */
|
|
18
|
+
const painters = new Map();
|
|
19
|
+
const painter = (level) => {
|
|
20
|
+
if (!painters.has(level)) painters.set(level, new Chalk({ level }));
|
|
21
|
+
return painters.get(level);
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** One frame every 85ms — just under twelve a second, smooth without being busy. */
|
|
25
|
+
export const FRAME_MS = 85;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A duration as a person says it: 0.4s, 14s, 2m 04s, 1h 07m.
|
|
29
|
+
*
|
|
30
|
+
* Seconds are zero-padded once there are minutes, so the text after the timer
|
|
31
|
+
* does not shift sideways every time the seconds roll from 9 to 10.
|
|
32
|
+
*/
|
|
33
|
+
export function formatDuration(ms) {
|
|
34
|
+
const value = Math.max(0, Number(ms) || 0);
|
|
35
|
+
if (value < 1000) return `${(value / 1000).toFixed(1)}s`;
|
|
36
|
+
const total = Math.floor(value / 1000);
|
|
37
|
+
if (total < 60) return `${total}s`;
|
|
38
|
+
const minutes = Math.floor(total / 60);
|
|
39
|
+
if (minutes < 60) return `${minutes}m ${String(total % 60).padStart(2, '0')}s`;
|
|
40
|
+
return `${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, '0')}m`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// The shimmer
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The two ends of the shimmer, both blue. The resting colour is muted enough
|
|
49
|
+
* to read as secondary text beside the model name; the peak is almost white,
|
|
50
|
+
* so the band reads as light passing over the words rather than a second
|
|
51
|
+
* colour arriving.
|
|
52
|
+
*/
|
|
53
|
+
const REST_RGB = [0x7a, 0x96, 0xc8];
|
|
54
|
+
const PEAK_RGB = [0xe6, 0xf0, 0xff];
|
|
55
|
+
|
|
56
|
+
/** Half the width of the band of light, in characters. */
|
|
57
|
+
const BAND = 3;
|
|
58
|
+
|
|
59
|
+
/** How fast the band travels, in characters a second. */
|
|
60
|
+
const SPEED = 24;
|
|
61
|
+
|
|
62
|
+
/** Characters' worth of dark between one pass and the next. */
|
|
63
|
+
const PAUSE = 18;
|
|
64
|
+
|
|
65
|
+
/** Brightness steps. Neighbouring letters that land on the same step share one escape code. */
|
|
66
|
+
const STEPS = 8;
|
|
67
|
+
|
|
68
|
+
const mix = (a, b, k) => a.map((v, i) => Math.round(v + (b[i] - v) * k));
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The text with a soft band of light passing across it, left to right, then a
|
|
72
|
+
* short rest, then again.
|
|
73
|
+
*
|
|
74
|
+
* `t` is milliseconds on any clock; the band's position is a function of it,
|
|
75
|
+
* so a slow frame skips ahead rather than slowing the sweep down.
|
|
76
|
+
*
|
|
77
|
+
* Needs 256 colours or more. With 16 there are no in-between blues to fade
|
|
78
|
+
* through, and a band that jumps between two colours reads as flicker rather
|
|
79
|
+
* than light — so below that the label is simply dim, and never moves.
|
|
80
|
+
*/
|
|
81
|
+
export function shimmer(text, t, { level = chalk.level } = {}) {
|
|
82
|
+
const s = String(text ?? '');
|
|
83
|
+
if (!s || level < 2) return dim(s);
|
|
84
|
+
|
|
85
|
+
const cycle = s.length + BAND * 2 + PAUSE;
|
|
86
|
+
const centre = ((Math.max(0, t) / 1000) * SPEED) % cycle - BAND;
|
|
87
|
+
|
|
88
|
+
let out = '';
|
|
89
|
+
let run = '';
|
|
90
|
+
let runStep = -1;
|
|
91
|
+
const flush = () => {
|
|
92
|
+
if (!run) return;
|
|
93
|
+
const [r, g, b] = mix(REST_RGB, PEAK_RGB, runStep / STEPS);
|
|
94
|
+
out += painter(level).rgb(r, g, b)(run);
|
|
95
|
+
run = '';
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
for (let i = 0; i < s.length; i++) {
|
|
99
|
+
const distance = Math.abs(i - centre);
|
|
100
|
+
// A cosine falloff: brightest at the centre, fading smoothly to nothing
|
|
101
|
+
// at the edge of the band, so the light has no hard edge to it.
|
|
102
|
+
const k = distance < BAND ? (Math.cos((Math.PI * distance) / BAND) + 1) / 2 : 0;
|
|
103
|
+
const step = Math.round(k * STEPS);
|
|
104
|
+
if (step !== runStep) { flush(); runStep = step; }
|
|
105
|
+
run += s[i];
|
|
106
|
+
}
|
|
107
|
+
flush();
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The spinner glyph for a frame, breathing slowly between two blues.
|
|
113
|
+
*
|
|
114
|
+
* The pulse is slow — a little over a second a breath — so it reads as the
|
|
115
|
+
* glyph being alive rather than as a blink.
|
|
116
|
+
*/
|
|
117
|
+
export function spinnerGlyph(frame, t, { level = chalk.level } = {}) {
|
|
118
|
+
const glyph = SPINNER[((frame % SPINNER.length) + SPINNER.length) % SPINNER.length];
|
|
119
|
+
if (level < 2) return theme.blue(glyph);
|
|
120
|
+
const k = (Math.sin((Math.max(0, t) / 1300) * Math.PI * 2) + 1) / 2;
|
|
121
|
+
const [r, g, b] = mix([0x4d, 0x8d, 0xff], [0x9f, 0xc6, 0xff], k);
|
|
122
|
+
return painter(level).rgb(r, g, b)(glyph);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// Fitting it into the room there is
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
/** Shorter than this, a label is a stub that says nothing, so it goes entirely. */
|
|
130
|
+
const MIN_LABEL = 10;
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The middle of the status row, fitted to `room` columns.
|
|
134
|
+
*
|
|
135
|
+
* Parts, in the order they are given up when the terminal is too narrow for
|
|
136
|
+
* all of them:
|
|
137
|
+
*
|
|
138
|
+
* 1. the "esc to stop" hint — useful once, known after that
|
|
139
|
+
* 2. the end of the label — clipped with an ellipsis, down to a stub
|
|
140
|
+
* 3. the step count
|
|
141
|
+
* 4. the label itself
|
|
142
|
+
* 5. the elapsed time
|
|
143
|
+
*
|
|
144
|
+
* The spinner is the last thing standing: even with a single column left the
|
|
145
|
+
* row still shows that something is happening.
|
|
146
|
+
*
|
|
147
|
+
* `meta` is a list of { text, paint, keep } — keep marks the one that survives
|
|
148
|
+
* the longest (the timer). `paint` colours the label, which is where the
|
|
149
|
+
* shimmer comes in.
|
|
150
|
+
*/
|
|
151
|
+
export function fitActivity({ glyph, label = '', meta = [], hint = '', paint = dim }, room) {
|
|
152
|
+
if (room < 1) return '';
|
|
153
|
+
const items = meta.filter((m) => m && m.text);
|
|
154
|
+
const kept = items.filter((m) => m.keep);
|
|
155
|
+
const text = String(label ?? '');
|
|
156
|
+
|
|
157
|
+
const width = (labelLen, list, withHint) =>
|
|
158
|
+
1 +
|
|
159
|
+
(labelLen ? 1 + labelLen : 0) +
|
|
160
|
+
(list.length ? (labelLen ? 3 : 1) + list.map((m) => m.text).join(' · ').length : 0) +
|
|
161
|
+
(withHint && hint ? 2 + hint.length : 0);
|
|
162
|
+
|
|
163
|
+
const build = (labelText, list, withHint) => {
|
|
164
|
+
let out = glyph;
|
|
165
|
+
if (labelText) out += ` ${paint(labelText)}`;
|
|
166
|
+
if (list.length) {
|
|
167
|
+
out += labelText ? dim(' · ') : ' ';
|
|
168
|
+
out += list.map((m) => (m.paint ?? dim)(m.text)).join(dim(' · '));
|
|
169
|
+
}
|
|
170
|
+
if (withHint && hint) out += ` ${dim(hint)}`;
|
|
171
|
+
return out;
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
if (text) {
|
|
175
|
+
if (width(text.length, items, true) <= room) return build(text, items, true);
|
|
176
|
+
if (width(text.length, items, false) <= room) return build(text, items, false);
|
|
177
|
+
for (const list of [items, kept]) {
|
|
178
|
+
const labelRoom = room - width(0, list, false) - 1 - (list.length ? 2 : 0);
|
|
179
|
+
if (labelRoom >= MIN_LABEL) return build(clip(text, labelRoom), list, false);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
for (const list of [items, kept, []]) {
|
|
183
|
+
if (width(0, list, false) <= room) return build('', list, false);
|
|
184
|
+
}
|
|
185
|
+
return glyph;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The line a finished turn leaves in the transcript: "✓ Done in 6m 12s · 25 steps".
|
|
190
|
+
*
|
|
191
|
+
* Green for the tick, because green means done and nothing else in this
|
|
192
|
+
* theme; the rest dim, because it is a footnote to the answer above it rather
|
|
193
|
+
* than something to read first.
|
|
194
|
+
*/
|
|
195
|
+
export function doneLine(ms, steps) {
|
|
196
|
+
const count = steps > 0 ? ` · ${steps} step${steps === 1 ? '' : 's'}` : '';
|
|
197
|
+
return `${theme.ok('✓')} ${dim(`Done in ${formatDuration(ms)}${count}`)}`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** The step count, brighter for a moment right after it goes up. */
|
|
201
|
+
export function stepPaint(justMoved) {
|
|
202
|
+
return justMoved ? sky : dim;
|
|
203
|
+
}
|
package/src/ui/plain.js
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
theme, blue, sky, dim, boxTop, boxBottom, boxRow,
|
|
16
16
|
BANNER, BANNER_WIDTH, SPINNER, clip, shortenPath, asLabel, padVis, visLen, planLine,
|
|
17
17
|
} from './theme.js';
|
|
18
|
+
import { formatDuration, doneLine } from './activity.js';
|
|
18
19
|
import { renderer, render } from './markdown.js';
|
|
19
20
|
|
|
20
21
|
const COMMANDS = [
|
|
@@ -227,9 +228,11 @@ export class Plain {
|
|
|
227
228
|
}
|
|
228
229
|
|
|
229
230
|
paintSpinner() {
|
|
230
|
-
const
|
|
231
|
-
const
|
|
232
|
-
|
|
231
|
+
const since = this.turn?.start ?? this.since;
|
|
232
|
+
const secs = Math.round((Date.now() - since) / 1000);
|
|
233
|
+
const meta = [this.turn?.steps ? `step ${this.turn.steps}` : '', secs >= 2 ? formatDuration(secs * 1000) : '']
|
|
234
|
+
.filter(Boolean).join(' · ');
|
|
235
|
+
const line = ` ${blue(SPINNER[this.frame])} ${dim(this.spinnerText)}` + (meta ? dim(` · ${meta}`) : '');
|
|
233
236
|
this.output.write(`\r\x1b[K${padVis(line, this.width() - 1)}`);
|
|
234
237
|
}
|
|
235
238
|
|
|
@@ -246,6 +249,22 @@ export class Plain {
|
|
|
246
249
|
this.output.write('\r\x1b[K');
|
|
247
250
|
}
|
|
248
251
|
|
|
252
|
+
// -- the turn in flight ----------------------------------------------------
|
|
253
|
+
|
|
254
|
+
turnStart() {
|
|
255
|
+
this.turn = { start: Date.now(), steps: 0 };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
step() {
|
|
259
|
+
if (this.turn) this.turn.steps++;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
turnEnd({ ok = true } = {}) {
|
|
263
|
+
const t = this.turn;
|
|
264
|
+
this.turn = null;
|
|
265
|
+
if (t && ok && Date.now() - t.start >= 2000) this.write(` ${doneLine(Date.now() - t.start, t.steps)}`);
|
|
266
|
+
}
|
|
267
|
+
|
|
249
268
|
// -- input ---------------------------------------------------------------
|
|
250
269
|
|
|
251
270
|
nextLine() {
|