@omnitype-code/cli 0.1.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/dist/blame.js +242 -0
- package/dist/core/ApiClient.js +234 -0
- package/dist/core/FileProvenance.js +483 -0
- package/dist/core/GitNotes.js +120 -0
- package/dist/core/Heartbeat.js +81 -0
- package/dist/core/ModelDetector.js +243 -0
- package/dist/core/ProvenanceResolver.js +424 -0
- package/dist/core/UI.js +97 -0
- package/dist/daemon.js +194 -0
- package/dist/hooks.js +220 -0
- package/dist/index.js +536 -0
- package/package.json +30 -0
- package/src/blame.ts +240 -0
- package/src/core/ApiClient.ts +197 -0
- package/src/core/FileProvenance.ts +538 -0
- package/src/core/GitNotes.ts +141 -0
- package/src/core/Heartbeat.ts +53 -0
- package/src/core/ModelDetector.ts +216 -0
- package/src/core/ProvenanceResolver.ts +433 -0
- package/src/core/UI.ts +105 -0
- package/src/daemon.ts +171 -0
- package/src/hooks.ts +195 -0
- package/src/index.ts +537 -0
- package/tsconfig.json +15 -0
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ProvenanceResolver — merges git notes + .omni (StoredProvenance) into a
|
|
3
|
+
* single authoritative hash→LineInfo map for a file.
|
|
4
|
+
*
|
|
5
|
+
* Precedence (higher wins):
|
|
6
|
+
* ai(3) > paste(2) > user(1) > existing(0)
|
|
7
|
+
*
|
|
8
|
+
* Rule:
|
|
9
|
+
* - git notes are authoritative for committed history
|
|
10
|
+
* - .omni fills gaps where notes say 'existing'
|
|
11
|
+
* - own tracked (non-existing) origins are NEVER overwritten
|
|
12
|
+
* - trivial lines (charLen ≤ MIN_HINT_CHARLEN) are skipped to guard
|
|
13
|
+
* against FNV hash collisions
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { execFileSync } from 'child_process';
|
|
17
|
+
import * as fs from 'fs';
|
|
18
|
+
import * as path from 'path';
|
|
19
|
+
import { readNote } from './GitNotes';
|
|
20
|
+
import type { NoteRange } from './GitNotes';
|
|
21
|
+
|
|
22
|
+
export type Origin = 'ai' | 'user' | 'paste' | 'existing';
|
|
23
|
+
|
|
24
|
+
export interface LineInfo {
|
|
25
|
+
origin: Origin;
|
|
26
|
+
model?: string;
|
|
27
|
+
tool?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** hash → LineInfo */
|
|
31
|
+
export type HintMap = Map<number, LineInfo>;
|
|
32
|
+
|
|
33
|
+
// Origin precedence: ai=3, paste=2, user=1, existing=0
|
|
34
|
+
const ORIGIN_PREC: Record<Origin, number> = { existing: 0, user: 1, paste: 2, ai: 3 };
|
|
35
|
+
|
|
36
|
+
const ORIGIN_DECODE: Origin[] = ['existing', 'user', 'ai', 'paste'];
|
|
37
|
+
|
|
38
|
+
const MIN_HINT_CHARLEN = 8;
|
|
39
|
+
|
|
40
|
+
// ── FNV-1a 32-bit (matches extension's TypingTracker.hashLine) ───────────────
|
|
41
|
+
export function fnv1a(s: string): number {
|
|
42
|
+
const canonical = s.trim();
|
|
43
|
+
let h = 2166136261 >>> 0;
|
|
44
|
+
for (let i = 0; i < canonical.length; i++) {
|
|
45
|
+
h = (h ^ canonical.charCodeAt(i)) >>> 0;
|
|
46
|
+
h = Math.imul(h, 16777619) >>> 0;
|
|
47
|
+
}
|
|
48
|
+
return h;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ── StoredProvenance (mirrors extension's interface) ─────────────────────────
|
|
52
|
+
export interface StoredProvenance {
|
|
53
|
+
hashes: number[];
|
|
54
|
+
origins: number[];
|
|
55
|
+
charLens: number[];
|
|
56
|
+
modelDictionary?: string[];
|
|
57
|
+
modelIds?: number[];
|
|
58
|
+
toolDictionary?: string[];
|
|
59
|
+
toolIds?: number[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
type OmniDisk = Record<string, Record<string, StoredProvenance>>;
|
|
63
|
+
|
|
64
|
+
// ── Merge helpers ─────────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
/** Merge two HintMaps — higher precedence origin wins per hash. */
|
|
67
|
+
export function mergeHints(...maps: HintMap[]): HintMap {
|
|
68
|
+
const out: HintMap = new Map();
|
|
69
|
+
for (const map of maps) {
|
|
70
|
+
for (const [hash, info] of map) {
|
|
71
|
+
const prev = out.get(hash);
|
|
72
|
+
if (!prev || ORIGIN_PREC[info.origin] > ORIGIN_PREC[prev.origin]) {
|
|
73
|
+
out.set(hash, info);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── Source: git notes ─────────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Build a HintMap by scanning git notes on the last N commits for a file.
|
|
84
|
+
* Returns hash→LineInfo by hashing each line's content from the working tree.
|
|
85
|
+
*/
|
|
86
|
+
export function hintsFromGitNotes(
|
|
87
|
+
repoPath: string,
|
|
88
|
+
relPath: string,
|
|
89
|
+
absPath: string,
|
|
90
|
+
maxCommits = 50,
|
|
91
|
+
): HintMap {
|
|
92
|
+
const map: HintMap = new Map();
|
|
93
|
+
|
|
94
|
+
// Read current file to build hash→lineContent lookup
|
|
95
|
+
let fileLines: string[];
|
|
96
|
+
try {
|
|
97
|
+
fileLines = fs.readFileSync(absPath, 'utf8').split('\n');
|
|
98
|
+
} catch { return map; }
|
|
99
|
+
|
|
100
|
+
// Scan recent commits for a note that covers this file
|
|
101
|
+
let ranges: NoteRange[] | undefined;
|
|
102
|
+
try {
|
|
103
|
+
const log = execFileSync(
|
|
104
|
+
'git', ['-C', repoPath, 'log', '--format=%H', `-${maxCommits}`],
|
|
105
|
+
{ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
106
|
+
).trim().split('\n').filter(Boolean);
|
|
107
|
+
|
|
108
|
+
for (const sha of log) {
|
|
109
|
+
const note = readNote(repoPath, sha);
|
|
110
|
+
// Extension writes git note keys with a leading '/' (same as .omni keys)
|
|
111
|
+
const fileData = note?.files[relPath] ?? note?.files['/' + relPath];
|
|
112
|
+
if (fileData) { ranges = fileData; break; }
|
|
113
|
+
}
|
|
114
|
+
} catch { return map; }
|
|
115
|
+
|
|
116
|
+
if (!ranges) return map;
|
|
117
|
+
|
|
118
|
+
// Expand ranges → line number → LineInfo, then hash the actual line content
|
|
119
|
+
for (const r of ranges) {
|
|
120
|
+
if (r.origin === 'existing') continue;
|
|
121
|
+
for (let ln = r.start; ln <= r.end; ln++) {
|
|
122
|
+
const content = fileLines[ln - 1]; // 1-indexed
|
|
123
|
+
if (content === undefined) continue;
|
|
124
|
+
if (content.trim().length < MIN_HINT_CHARLEN) continue;
|
|
125
|
+
const hash = fnv1a(content);
|
|
126
|
+
const prev = map.get(hash);
|
|
127
|
+
if (!prev || ORIGIN_PREC[r.origin] > ORIGIN_PREC[prev.origin]) {
|
|
128
|
+
map.set(hash, { origin: r.origin, model: r.model, tool: r.tool });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return map;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ── Source: .omni file ────────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
/** Read .omni in repoPath and return a HintMap for the given branch + relPath. */
|
|
139
|
+
/**
|
|
140
|
+
* Load .omni and reconcile stored line hashes against current file content
|
|
141
|
+
* using LCS — same algorithm as FileProvenance.fromStored() in the extension.
|
|
142
|
+
*
|
|
143
|
+
* Returns a 1-indexed lineNumber → LineInfo map for the current file.
|
|
144
|
+
* Lines that don't match any stored line get no entry (treated as existing).
|
|
145
|
+
*/
|
|
146
|
+
export function lineMapFromOmni(
|
|
147
|
+
repoPath: string,
|
|
148
|
+
branch: string,
|
|
149
|
+
relPath: string,
|
|
150
|
+
currentLines: string[],
|
|
151
|
+
): Map<number, LineInfo> {
|
|
152
|
+
const result = new Map<number, LineInfo>();
|
|
153
|
+
const omniPath = path.join(repoPath, '.omni');
|
|
154
|
+
if (!fs.existsSync(omniPath)) return result;
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
const disk: OmniDisk = JSON.parse(fs.readFileSync(omniPath, 'utf8'));
|
|
158
|
+
const branchData = disk[branch] ?? disk[Object.keys(disk)[0]];
|
|
159
|
+
if (!branchData) return result;
|
|
160
|
+
// .omni keys use URI-relative paths with a leading '/' (extension writes '/src/foo.ts').
|
|
161
|
+
// Prefer the '/'-prefixed key — it is always the canonical extension-written entry.
|
|
162
|
+
// Fall back to the unprefixed form for CLI-written entries.
|
|
163
|
+
const stored = branchData['/' + relPath] ?? branchData[relPath];
|
|
164
|
+
if (!stored) return result;
|
|
165
|
+
|
|
166
|
+
const storedHashes = stored.hashes;
|
|
167
|
+
const currentHashes = currentLines.map(l => fnv1a(l));
|
|
168
|
+
const m = storedHashes.length;
|
|
169
|
+
const n = currentHashes.length;
|
|
170
|
+
|
|
171
|
+
// LCS table (same as FileProvenance.fromStored)
|
|
172
|
+
const MAX_LCS = 3000; // match extension's FileProvenance.MAX_LCS_SIDE
|
|
173
|
+
if (m > MAX_LCS || n > MAX_LCS) {
|
|
174
|
+
// Fall back to direct hash lookup for very large files
|
|
175
|
+
const hashToInfo = new Map<number, LineInfo>();
|
|
176
|
+
for (let i = 0; i < m; i++) {
|
|
177
|
+
const origin = ORIGIN_DECODE[stored.origins[i] ?? 0] ?? 'existing';
|
|
178
|
+
if (origin === 'existing') continue;
|
|
179
|
+
const modelId = stored.modelIds?.[i] ?? -1;
|
|
180
|
+
const model = modelId >= 0 ? stored.modelDictionary?.[modelId] : undefined;
|
|
181
|
+
const toolId = stored.toolIds?.[i] ?? -1;
|
|
182
|
+
const tool = toolId >= 0 ? stored.toolDictionary?.[toolId] : undefined;
|
|
183
|
+
hashToInfo.set(storedHashes[i], { origin, model, tool });
|
|
184
|
+
}
|
|
185
|
+
currentLines.forEach((line, idx) => {
|
|
186
|
+
const info = hashToInfo.get(currentHashes[idx]);
|
|
187
|
+
if (info && info.origin !== 'existing') result.set(idx + 1, info);
|
|
188
|
+
});
|
|
189
|
+
return result;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const t: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
|
193
|
+
for (let i = 1; i <= m; i++) {
|
|
194
|
+
for (let j = 1; j <= n; j++) {
|
|
195
|
+
t[i][j] = storedHashes[i - 1] === currentHashes[j - 1]
|
|
196
|
+
? t[i - 1][j - 1] + 1
|
|
197
|
+
: Math.max(t[i - 1][j], t[i][j - 1]);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Backtrack — assign stored origins to matched current lines
|
|
202
|
+
let i = m, j = n;
|
|
203
|
+
while (i > 0 && j > 0) {
|
|
204
|
+
if (storedHashes[i - 1] === currentHashes[j - 1] && t[i][j] === t[i - 1][j - 1] + 1) {
|
|
205
|
+
const origin = ORIGIN_DECODE[stored.origins[i - 1] ?? 0] ?? 'existing';
|
|
206
|
+
if (origin !== 'existing') {
|
|
207
|
+
const modelId = stored.modelIds?.[i - 1] ?? -1;
|
|
208
|
+
const model = modelId >= 0 ? stored.modelDictionary?.[modelId] : undefined;
|
|
209
|
+
const toolId = stored.toolIds?.[i - 1] ?? -1;
|
|
210
|
+
const tool = toolId >= 0 ? stored.toolDictionary?.[toolId] : undefined;
|
|
211
|
+
result.set(j, { origin, model, tool }); // j is 1-indexed current line
|
|
212
|
+
}
|
|
213
|
+
i--; j--;
|
|
214
|
+
} else if (t[i - 1][j] >= t[i][j - 1]) {
|
|
215
|
+
i--;
|
|
216
|
+
} else {
|
|
217
|
+
j--;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
} catch { /* corrupt .omni */ }
|
|
221
|
+
|
|
222
|
+
return result;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ── Apply hints to StoredProvenance ──────────────────────────────────────────
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Upgrade 'existing' lines in a StoredProvenance using a HintMap.
|
|
229
|
+
* Only lines with origin=existing (0) are touched — own tracked origins stay.
|
|
230
|
+
* Returns true if any lines were upgraded.
|
|
231
|
+
*/
|
|
232
|
+
export function applyHints(stored: StoredProvenance, hints: HintMap): boolean {
|
|
233
|
+
if (hints.size === 0) return false;
|
|
234
|
+
let changed = false;
|
|
235
|
+
|
|
236
|
+
for (let i = 0; i < stored.hashes.length; i++) {
|
|
237
|
+
if (stored.origins[i] !== 0) continue; // never overwrite tracked origin
|
|
238
|
+
if ((stored.charLens[i] ?? 0) < MIN_HINT_CHARLEN) continue;
|
|
239
|
+
|
|
240
|
+
const hint = hints.get(stored.hashes[i]);
|
|
241
|
+
if (!hint || hint.origin === 'existing') continue;
|
|
242
|
+
|
|
243
|
+
const originCode = ORIGIN_DECODE.indexOf(hint.origin);
|
|
244
|
+
if (originCode < 0) continue;
|
|
245
|
+
|
|
246
|
+
stored.origins[i] = originCode;
|
|
247
|
+
changed = true;
|
|
248
|
+
|
|
249
|
+
// Inject model
|
|
250
|
+
if (hint.model) {
|
|
251
|
+
stored.modelDictionary ??= [];
|
|
252
|
+
stored.modelIds ??= new Array(stored.hashes.length).fill(-1);
|
|
253
|
+
let mIdx = stored.modelDictionary.indexOf(hint.model);
|
|
254
|
+
if (mIdx === -1) { mIdx = stored.modelDictionary.length; stored.modelDictionary.push(hint.model); }
|
|
255
|
+
stored.modelIds[i] = mIdx;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Inject tool
|
|
259
|
+
if (hint.tool) {
|
|
260
|
+
stored.toolDictionary ??= [];
|
|
261
|
+
stored.toolIds ??= new Array(stored.hashes.length).fill(-1);
|
|
262
|
+
let tIdx = stored.toolDictionary.indexOf(hint.tool);
|
|
263
|
+
if (tIdx === -1) { tIdx = stored.toolDictionary.length; stored.toolDictionary.push(hint.tool); }
|
|
264
|
+
stored.toolIds[i] = tIdx;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return changed;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ── High-level: resolve a file's full line map ────────────────────────────────
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Build a lineNumber→LineInfo map from a StoredProvenance fetched from the API.
|
|
275
|
+
* Uses LCS reconciliation against current file lines — same as lineMapFromOmni.
|
|
276
|
+
*/
|
|
277
|
+
export function lineMapFromApiStored(
|
|
278
|
+
stored: StoredProvenance,
|
|
279
|
+
currentLines: string[],
|
|
280
|
+
): Map<number, LineInfo> {
|
|
281
|
+
return lineMapFromStoredInner(stored, currentLines);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function lineMapFromStoredInner(
|
|
285
|
+
stored: StoredProvenance,
|
|
286
|
+
currentLines: string[],
|
|
287
|
+
): Map<number, LineInfo> {
|
|
288
|
+
const result = new Map<number, LineInfo>();
|
|
289
|
+
const storedHashes = stored.hashes;
|
|
290
|
+
const currentHashes = currentLines.map(l => fnv1a(l));
|
|
291
|
+
const m = storedHashes.length;
|
|
292
|
+
const n = currentHashes.length;
|
|
293
|
+
|
|
294
|
+
const MAX_LCS = 3000; // match extension's FileProvenance.MAX_LCS_SIDE
|
|
295
|
+
if (m > MAX_LCS || n > MAX_LCS) {
|
|
296
|
+
const hashToInfo = new Map<number, LineInfo>();
|
|
297
|
+
for (let i = 0; i < m; i++) {
|
|
298
|
+
const origin = ORIGIN_DECODE[stored.origins[i] ?? 0] ?? 'existing';
|
|
299
|
+
if (origin === 'existing') continue;
|
|
300
|
+
const modelId = stored.modelIds?.[i] ?? -1;
|
|
301
|
+
const model = modelId >= 0 ? stored.modelDictionary?.[modelId] : undefined;
|
|
302
|
+
const toolId = stored.toolIds?.[i] ?? -1;
|
|
303
|
+
const tool = toolId >= 0 ? stored.toolDictionary?.[toolId] : undefined;
|
|
304
|
+
hashToInfo.set(storedHashes[i], { origin, model, tool });
|
|
305
|
+
}
|
|
306
|
+
currentLines.forEach((line, idx) => {
|
|
307
|
+
const info = hashToInfo.get(currentHashes[idx]);
|
|
308
|
+
if (info && info.origin !== 'existing') result.set(idx + 1, info);
|
|
309
|
+
});
|
|
310
|
+
return result;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const t: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
|
314
|
+
for (let i = 1; i <= m; i++)
|
|
315
|
+
for (let j = 1; j <= n; j++)
|
|
316
|
+
t[i][j] = storedHashes[i - 1] === currentHashes[j - 1]
|
|
317
|
+
? t[i - 1][j - 1] + 1
|
|
318
|
+
: Math.max(t[i - 1][j], t[i][j - 1]);
|
|
319
|
+
|
|
320
|
+
let i = m, j = n;
|
|
321
|
+
while (i > 0 && j > 0) {
|
|
322
|
+
if (storedHashes[i - 1] === currentHashes[j - 1] && t[i][j] === t[i - 1][j - 1] + 1) {
|
|
323
|
+
const origin = ORIGIN_DECODE[stored.origins[i - 1] ?? 0] ?? 'existing';
|
|
324
|
+
if (origin !== 'existing') {
|
|
325
|
+
const modelId = stored.modelIds?.[i - 1] ?? -1;
|
|
326
|
+
const model = modelId >= 0 ? stored.modelDictionary?.[modelId] : undefined;
|
|
327
|
+
const toolId = stored.toolIds?.[i - 1] ?? -1;
|
|
328
|
+
const tool = toolId >= 0 ? stored.toolDictionary?.[toolId] : undefined;
|
|
329
|
+
result.set(j, { origin, model, tool });
|
|
330
|
+
}
|
|
331
|
+
i--; j--;
|
|
332
|
+
} else if (t[i - 1][j] >= t[i][j - 1]) {
|
|
333
|
+
i--;
|
|
334
|
+
} else {
|
|
335
|
+
j--;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return result;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Resolve the authoritative LineInfo for every line of a file.
|
|
343
|
+
* Priority: API (live cloud data) > git notes > .omni
|
|
344
|
+
*
|
|
345
|
+
* Used by `omnitype blame` and any CLI command that needs per-line attribution.
|
|
346
|
+
*/
|
|
347
|
+
export async function resolveFileLinesAsync(
|
|
348
|
+
repoPath: string,
|
|
349
|
+
relPath: string,
|
|
350
|
+
absPath: string,
|
|
351
|
+
branch: string,
|
|
352
|
+
projectName: string,
|
|
353
|
+
): Promise<Map<number, LineInfo>> {
|
|
354
|
+
let currentLines: string[];
|
|
355
|
+
try {
|
|
356
|
+
currentLines = fs.readFileSync(absPath, 'utf8').split('\n');
|
|
357
|
+
} catch { return new Map(); }
|
|
358
|
+
|
|
359
|
+
// Start with local sources (.omni + git notes) — most complete for current device
|
|
360
|
+
const localMap = resolveFileLines(repoPath, relPath, absPath, branch);
|
|
361
|
+
|
|
362
|
+
// Merge with API — fills lines local doesn't have (pushed from other devices)
|
|
363
|
+
// Local wins when it already has attribution; API only adds what's missing
|
|
364
|
+
try {
|
|
365
|
+
const { ApiClient } = await import('./ApiClient');
|
|
366
|
+
const api = new ApiClient();
|
|
367
|
+
if (api.isSignedIn) {
|
|
368
|
+
const files = await api.pullProvenance(projectName, [relPath, '/' + relPath]);
|
|
369
|
+
const apiStored = files?.[relPath] ?? files?.['/' + relPath];
|
|
370
|
+
if (apiStored?.hashes?.length) {
|
|
371
|
+
const apiMap = lineMapFromStoredInner(apiStored as StoredProvenance, currentLines);
|
|
372
|
+
for (const [lineNum, apiInfo] of apiMap) {
|
|
373
|
+
if (!localMap.has(lineNum)) {
|
|
374
|
+
// API has data local doesn't — add it
|
|
375
|
+
localMap.set(lineNum, apiInfo);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
} catch { /* use local only */ }
|
|
381
|
+
|
|
382
|
+
return localMap;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Synchronous fallback — .omni + git notes only.
|
|
387
|
+
*/
|
|
388
|
+
export function resolveFileLines(
|
|
389
|
+
repoPath: string,
|
|
390
|
+
relPath: string,
|
|
391
|
+
absPath: string,
|
|
392
|
+
branch: string,
|
|
393
|
+
): Map<number, LineInfo> {
|
|
394
|
+
let currentLines: string[];
|
|
395
|
+
try {
|
|
396
|
+
currentLines = fs.readFileSync(absPath, 'utf8').split('\n');
|
|
397
|
+
} catch { return new Map(); }
|
|
398
|
+
|
|
399
|
+
const omniLineMap = lineMapFromOmni(repoPath, branch, relPath, currentLines);
|
|
400
|
+
const gitHints = hintsFromGitNotes(repoPath, relPath, absPath);
|
|
401
|
+
|
|
402
|
+
const lineMap = new Map<number, LineInfo>(omniLineMap);
|
|
403
|
+
for (const [lineNum, info] of lineMap) {
|
|
404
|
+
const gitInfo = gitHints.get(fnv1a(currentLines[lineNum - 1] ?? ''));
|
|
405
|
+
if (gitInfo && ORIGIN_PREC[gitInfo.origin] > ORIGIN_PREC[info.origin]) {
|
|
406
|
+
lineMap.set(lineNum, gitInfo);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
currentLines.forEach((content, idx) => {
|
|
410
|
+
const lineNum = idx + 1;
|
|
411
|
+
if (lineMap.has(lineNum)) return;
|
|
412
|
+
const gitInfo = gitHints.get(fnv1a(content));
|
|
413
|
+
if (gitInfo && gitInfo.origin !== 'existing') lineMap.set(lineNum, gitInfo);
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
return lineMap;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
export function getCurrentBranch(repoPath: string): string {
|
|
420
|
+
try {
|
|
421
|
+
return execFileSync('git', ['-C', repoPath, 'rev-parse', '--abbrev-ref', 'HEAD'],
|
|
422
|
+
{ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
423
|
+
} catch { return 'main'; }
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
export function findRepoRoot(startPath: string): string | undefined {
|
|
427
|
+
try {
|
|
428
|
+
return execFileSync(
|
|
429
|
+
'git', ['-C', path.dirname(startPath), 'rev-parse', '--show-toplevel'],
|
|
430
|
+
{ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
431
|
+
).trim();
|
|
432
|
+
} catch { return undefined; }
|
|
433
|
+
}
|
package/src/core/UI.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import boxen from 'boxen';
|
|
3
|
+
import ora from 'ora';
|
|
4
|
+
import gradient from 'gradient-string';
|
|
5
|
+
|
|
6
|
+
export const COLORS = {
|
|
7
|
+
primary: '#00D1FF',
|
|
8
|
+
secondary: '#7000FF',
|
|
9
|
+
success: '#00FF94',
|
|
10
|
+
warning: '#FFB800',
|
|
11
|
+
error: '#FF4D4D',
|
|
12
|
+
ai: '#BD00FF',
|
|
13
|
+
user: '#00D1FF',
|
|
14
|
+
paste: '#FFB800',
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// Pixelated logo derived from the SVG: circle + dashed arc + center dot
|
|
18
|
+
const LOGO_PIXELS = [
|
|
19
|
+
' ▄▄▄▄▄▄▄▄▄▄▄ ',
|
|
20
|
+
' ▄█ █▄ ',
|
|
21
|
+
'██ ╌ ╌ ● ╌ ╌ ██',
|
|
22
|
+
' ▀█ █▀ ',
|
|
23
|
+
' ▀▀▀▀▀▀▀▀▀▀▀ ',
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
export class UI {
|
|
27
|
+
static pixelLogo(): string {
|
|
28
|
+
return LOGO_PIXELS
|
|
29
|
+
.map(line => gradient([COLORS.primary, COLORS.secondary, COLORS.ai])(line))
|
|
30
|
+
.join('\n');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
static banner(): string {
|
|
34
|
+
const g = (s: string) => chalk.bold(gradient([COLORS.primary, COLORS.secondary, COLORS.ai])(s));
|
|
35
|
+
return [
|
|
36
|
+
'',
|
|
37
|
+
g(' ██████╗ ███╗ ███╗███╗ ██╗██╗'),
|
|
38
|
+
g('██╔═══██╗████╗ ████║████╗ ██║██║'),
|
|
39
|
+
g('██║ ██║██╔████╔██║██╔██╗ ██║██║'),
|
|
40
|
+
g('██║ ██║██║╚██╔╝██║██║╚██╗██║██║'),
|
|
41
|
+
g('╚██████╔╝██║ ╚═╝ ██║██║ ╚████║██║'),
|
|
42
|
+
g(' ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═══╝╚═╝'),
|
|
43
|
+
chalk.hex(COLORS.primary).dim(' code provenance · any editor · any model'),
|
|
44
|
+
'',
|
|
45
|
+
].join('\n');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
static logo(): string {
|
|
49
|
+
return chalk.bold(gradient(COLORS.primary, COLORS.secondary)('OmniType'));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
static box(content: string, title?: string) {
|
|
53
|
+
return boxen(content, {
|
|
54
|
+
padding: 1,
|
|
55
|
+
margin: 1,
|
|
56
|
+
borderStyle: 'round',
|
|
57
|
+
borderColor: COLORS.primary,
|
|
58
|
+
title: title ? chalk.bold(gradient(COLORS.primary, COLORS.secondary)(title)) : undefined,
|
|
59
|
+
titleAlignment: 'center',
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
static spinner(text: string) {
|
|
64
|
+
return ora({
|
|
65
|
+
text,
|
|
66
|
+
color: 'cyan',
|
|
67
|
+
spinner: 'dots12',
|
|
68
|
+
}).start();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
static error(message: string) {
|
|
72
|
+
console.error(chalk.hex(COLORS.error)('✖ ') + message);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
static success(message: string) {
|
|
76
|
+
console.log(chalk.hex(COLORS.success)('✔ ') + message);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
static info(message: string) {
|
|
80
|
+
console.log(chalk.hex(COLORS.primary)('ℹ ') + message);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
static dim(message: string) {
|
|
84
|
+
return chalk.gray(message);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
static bold(message: string) {
|
|
88
|
+
return chalk.bold(message);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
static label(text: string, color: string = COLORS.primary) {
|
|
92
|
+
return chalk.bgHex(color).black(` ${text} `);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
static bar(value: number, total: number, width = 20, color = COLORS.primary): string {
|
|
96
|
+
const filled = total === 0 ? 0 : Math.round((value / total) * width);
|
|
97
|
+
return chalk.hex(color)('█'.repeat(filled)) + chalk.gray('░'.repeat(width - filled));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
static pct(value: number, total: number): string {
|
|
101
|
+
const p = total === 0 ? 0 : (value / total) * 100;
|
|
102
|
+
const color = p > 60 ? COLORS.ai : p > 30 ? COLORS.warning : COLORS.success;
|
|
103
|
+
return chalk.hex(color)(`${p.toFixed(1)}%`);
|
|
104
|
+
}
|
|
105
|
+
}
|