nitpiq 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/README.md +173 -0
- package/bin/nitpiq-mcp.ts +2 -0
- package/bin/nitpiq.ts +2 -0
- package/bunfig.toml +1 -0
- package/package.json +44 -0
- package/plugins/react-compiler.ts +28 -0
- package/src/cli/nitpiq-mcp.ts +10 -0
- package/src/cli/nitpiq.tsx +36 -0
- package/src/git/repo.ts +237 -0
- package/src/log/log.ts +27 -0
- package/src/mcp/server.test.ts +37 -0
- package/src/mcp/server.ts +210 -0
- package/src/review/anchor.ts +118 -0
- package/src/review/types.ts +64 -0
- package/src/store/store.ts +315 -0
- package/src/tui/app.tsx +1089 -0
- package/src/tui/demo.ts +241 -0
- package/src/tui/diff.ts +99 -0
- package/src/tui/highlight.ts +208 -0
- package/src/tui/theme.ts +107 -0
package/src/tui/demo.ts
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import type { FileChange, Repo } from "../git/repo";
|
|
2
|
+
import { AuthorHuman, AuthorModel, ThreadOpen, ThreadResolved, type Comment, type ReviewSession, type Thread } from "../review/types";
|
|
3
|
+
|
|
4
|
+
export interface DemoFile {
|
|
5
|
+
change: FileChange;
|
|
6
|
+
content: string;
|
|
7
|
+
diff: string;
|
|
8
|
+
threads: Thread[];
|
|
9
|
+
commentsByThread: Record<string, Comment[]>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface DemoState {
|
|
13
|
+
repo: Repo;
|
|
14
|
+
session: ReviewSession;
|
|
15
|
+
repoFiles: string[];
|
|
16
|
+
files: DemoFile[];
|
|
17
|
+
threadCounts: Record<string, number>;
|
|
18
|
+
status: string;
|
|
19
|
+
focus: "files" | "diff";
|
|
20
|
+
showFullFile: boolean;
|
|
21
|
+
fileCursor: number;
|
|
22
|
+
diffCursor: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function createDemoState(): DemoState {
|
|
26
|
+
const session: ReviewSession = {
|
|
27
|
+
id: "demo-session",
|
|
28
|
+
repoRoot: "/demo/nitpiq",
|
|
29
|
+
active: true,
|
|
30
|
+
createdAt: new Date("2026-03-09T12:00:00Z"),
|
|
31
|
+
updatedAt: new Date("2026-03-09T12:00:00Z"),
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const checkoutThread: Thread = {
|
|
35
|
+
id: "thread-checkout",
|
|
36
|
+
sessionId: session.id,
|
|
37
|
+
filePath: "src/checkout/applyCoupon.ts",
|
|
38
|
+
side: "new",
|
|
39
|
+
originalLine: 14,
|
|
40
|
+
lineEnd: 14,
|
|
41
|
+
currentLine: 14,
|
|
42
|
+
anchorContent: " if (!coupon || !cart.total) {",
|
|
43
|
+
contextBefore: "export function applyCoupon(cart: Cart, coupon?: Coupon) {",
|
|
44
|
+
contextAfter: " return cart;\n }",
|
|
45
|
+
isOutdated: false,
|
|
46
|
+
status: ThreadOpen,
|
|
47
|
+
createdAt: new Date("2026-03-09T12:00:00Z"),
|
|
48
|
+
updatedAt: new Date("2026-03-09T12:01:00Z"),
|
|
49
|
+
commentCount: 2,
|
|
50
|
+
firstComment: "This early return skips invalid coupon telemetry.",
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const threadsPanel: Thread = {
|
|
54
|
+
id: "thread-layout",
|
|
55
|
+
sessionId: session.id,
|
|
56
|
+
filePath: "src/tui/panels/files.tsx",
|
|
57
|
+
side: "new",
|
|
58
|
+
originalLine: 22,
|
|
59
|
+
lineEnd: 22,
|
|
60
|
+
currentLine: 22,
|
|
61
|
+
anchorContent: " <Text wrap=\"truncate-end\">{label}</Text>",
|
|
62
|
+
contextBefore: " <Box flexDirection=\"row\">",
|
|
63
|
+
contextAfter: " </Box>",
|
|
64
|
+
isOutdated: false,
|
|
65
|
+
status: ThreadResolved,
|
|
66
|
+
createdAt: new Date("2026-03-09T11:20:00Z"),
|
|
67
|
+
updatedAt: new Date("2026-03-09T11:45:00Z"),
|
|
68
|
+
commentCount: 1,
|
|
69
|
+
firstComment: "Looks better with single-line clipping.",
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const checkoutComments: Record<string, Comment[]> = {
|
|
73
|
+
[checkoutThread.id]: [
|
|
74
|
+
{
|
|
75
|
+
id: "comment-1",
|
|
76
|
+
threadId: checkoutThread.id,
|
|
77
|
+
author: AuthorModel,
|
|
78
|
+
body: "This early return skips the `trackCouponApplied` call, so invalid coupons won't appear in analytics. Consider emitting a separate `couponRejected` event before returning.",
|
|
79
|
+
createdAt: new Date("2026-03-09T12:00:00Z"),
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
id: "comment-2",
|
|
83
|
+
threadId: checkoutThread.id,
|
|
84
|
+
author: AuthorHuman,
|
|
85
|
+
body: "Good catch — I still want to keep the guard, but I can emit an event before returning.\nI'll add `trackCouponRejected({ code: coupon?.code })` right before the early return.",
|
|
86
|
+
createdAt: new Date("2026-03-09T12:01:00Z"),
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
id: "comment-3a",
|
|
90
|
+
threadId: checkoutThread.id,
|
|
91
|
+
author: AuthorModel,
|
|
92
|
+
body: "That sounds good. Make sure to handle the `coupon?.code` being `undefined` gracefully in the telemetry pipeline.",
|
|
93
|
+
createdAt: new Date("2026-03-09T12:02:00Z"),
|
|
94
|
+
},
|
|
95
|
+
],
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const filesComments: Record<string, Comment[]> = {
|
|
99
|
+
[threadsPanel.id]: [
|
|
100
|
+
{
|
|
101
|
+
id: "comment-3",
|
|
102
|
+
threadId: threadsPanel.id,
|
|
103
|
+
author: AuthorHuman,
|
|
104
|
+
body: "Looks better with single-line clipping.",
|
|
105
|
+
createdAt: new Date("2026-03-09T11:45:00Z"),
|
|
106
|
+
},
|
|
107
|
+
],
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const checkoutFile: DemoFile = {
|
|
111
|
+
change: {
|
|
112
|
+
path: "src/checkout/applyCoupon.ts",
|
|
113
|
+
oldPath: "",
|
|
114
|
+
kind: "modified",
|
|
115
|
+
staged: false,
|
|
116
|
+
unstaged: true,
|
|
117
|
+
},
|
|
118
|
+
content: [
|
|
119
|
+
"export function applyCoupon(cart: Cart, coupon?: Coupon) {",
|
|
120
|
+
" if (!coupon || !cart.total) {",
|
|
121
|
+
" return cart;",
|
|
122
|
+
" }",
|
|
123
|
+
"",
|
|
124
|
+
" const nextTotal = Math.max(0, cart.total - coupon.amount);",
|
|
125
|
+
"",
|
|
126
|
+
" trackCouponApplied({",
|
|
127
|
+
" code: coupon.code,",
|
|
128
|
+
" previousTotal: cart.total,",
|
|
129
|
+
" nextTotal,",
|
|
130
|
+
" });",
|
|
131
|
+
"",
|
|
132
|
+
" return { ...cart, total: nextTotal };",
|
|
133
|
+
"}",
|
|
134
|
+
].join("\n"),
|
|
135
|
+
diff: [
|
|
136
|
+
"diff --git a/src/checkout/applyCoupon.ts b/src/checkout/applyCoupon.ts",
|
|
137
|
+
"index 1234567..89abcde 100644",
|
|
138
|
+
"--- a/src/checkout/applyCoupon.ts",
|
|
139
|
+
"+++ b/src/checkout/applyCoupon.ts",
|
|
140
|
+
"@@ -1,7 +1,14 @@",
|
|
141
|
+
" export function applyCoupon(cart: Cart, coupon?: Coupon) {",
|
|
142
|
+
"- if (!coupon) {",
|
|
143
|
+
"+ if (!coupon || !cart.total) {",
|
|
144
|
+
" return cart;",
|
|
145
|
+
" }",
|
|
146
|
+
" ",
|
|
147
|
+
"+ const nextTotal = Math.max(0, cart.total - coupon.amount);",
|
|
148
|
+
"+",
|
|
149
|
+
"+ trackCouponApplied({",
|
|
150
|
+
"+ code: coupon.code,",
|
|
151
|
+
"+ previousTotal: cart.total,",
|
|
152
|
+
"+ nextTotal,",
|
|
153
|
+
"+ });",
|
|
154
|
+
"- return { ...cart, total: cart.total - coupon.amount };",
|
|
155
|
+
"+ return { ...cart, total: nextTotal };",
|
|
156
|
+
" }",
|
|
157
|
+
].join("\n"),
|
|
158
|
+
threads: [checkoutThread],
|
|
159
|
+
commentsByThread: checkoutComments,
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const filesPaneFile: DemoFile = {
|
|
163
|
+
change: {
|
|
164
|
+
path: "src/tui/panels/files.tsx",
|
|
165
|
+
oldPath: "",
|
|
166
|
+
kind: "modified",
|
|
167
|
+
staged: true,
|
|
168
|
+
unstaged: false,
|
|
169
|
+
},
|
|
170
|
+
content: [
|
|
171
|
+
"export function FileRow({ active, label }: Props) {",
|
|
172
|
+
" return (",
|
|
173
|
+
" <Box flexDirection=\"row\">",
|
|
174
|
+
" <Text color={active ? \"blue\" : undefined}>{active ? \">\" : \" \"}</Text>",
|
|
175
|
+
" <Text wrap=\"truncate-end\">{label}</Text>",
|
|
176
|
+
" </Box>",
|
|
177
|
+
" );",
|
|
178
|
+
"}",
|
|
179
|
+
].join("\n"),
|
|
180
|
+
diff: [
|
|
181
|
+
"diff --git a/src/tui/panels/files.tsx b/src/tui/panels/files.tsx",
|
|
182
|
+
"index abcdef0..1234567 100644",
|
|
183
|
+
"--- a/src/tui/panels/files.tsx",
|
|
184
|
+
"+++ b/src/tui/panels/files.tsx",
|
|
185
|
+
"@@ -2,6 +2,7 @@",
|
|
186
|
+
" return (",
|
|
187
|
+
" <Box flexDirection=\"row\">",
|
|
188
|
+
"+ <Text color={active ? \"blue\" : undefined}>{active ? \">\" : \" \"}</Text>",
|
|
189
|
+
" <Text wrap=\"truncate-end\">{label}</Text>",
|
|
190
|
+
" </Box>",
|
|
191
|
+
" );",
|
|
192
|
+
].join("\n"),
|
|
193
|
+
threads: [threadsPanel],
|
|
194
|
+
commentsByThread: filesComments,
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const readmeFile: DemoFile = {
|
|
198
|
+
change: {
|
|
199
|
+
path: "README.md",
|
|
200
|
+
oldPath: "",
|
|
201
|
+
kind: "added",
|
|
202
|
+
staged: false,
|
|
203
|
+
unstaged: true,
|
|
204
|
+
},
|
|
205
|
+
content: "# Nit Demo\n\nTerminal review UI snapshot mode.",
|
|
206
|
+
diff: [
|
|
207
|
+
"diff --git a/README.md b/README.md",
|
|
208
|
+
"new file mode 100644",
|
|
209
|
+
"--- /dev/null",
|
|
210
|
+
"+++ b/README.md",
|
|
211
|
+
"@@ -0,0 +1,3 @@",
|
|
212
|
+
"+# Nit Demo",
|
|
213
|
+
"+",
|
|
214
|
+
"+Terminal review UI snapshot mode.",
|
|
215
|
+
].join("\n"),
|
|
216
|
+
threads: [],
|
|
217
|
+
commentsByThread: {},
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const files = [checkoutFile, filesPaneFile, readmeFile];
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
repo: {
|
|
224
|
+
root: "/demo/nitpiq",
|
|
225
|
+
name: "nitpiq-demo",
|
|
226
|
+
hasHead: true,
|
|
227
|
+
},
|
|
228
|
+
session,
|
|
229
|
+
repoFiles: files.map((file) => file.change.path),
|
|
230
|
+
files,
|
|
231
|
+
threadCounts: {
|
|
232
|
+
"src/checkout/applyCoupon.ts": 1,
|
|
233
|
+
"src/tui/panels/files.tsx": 0,
|
|
234
|
+
},
|
|
235
|
+
status: "Demo mode - fixed data for UI snapshots",
|
|
236
|
+
focus: "diff",
|
|
237
|
+
showFullFile: false,
|
|
238
|
+
fileCursor: 0,
|
|
239
|
+
diffCursor: 18,
|
|
240
|
+
};
|
|
241
|
+
}
|
package/src/tui/diff.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { Thread } from "../review/types";
|
|
2
|
+
|
|
3
|
+
export type DiffRowKind = "header" | "hunk" | "context" | "add" | "delete" | "meta";
|
|
4
|
+
|
|
5
|
+
export interface DiffRow {
|
|
6
|
+
kind: DiffRowKind;
|
|
7
|
+
text: string;
|
|
8
|
+
oldLine: number | null;
|
|
9
|
+
newLine: number | null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ThreadMarker {
|
|
13
|
+
thread: Thread;
|
|
14
|
+
preview: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function parseDiffRows(diffText: string): DiffRow[] {
|
|
18
|
+
const rows: DiffRow[] = [];
|
|
19
|
+
let oldLine = 0;
|
|
20
|
+
let newLine = 0;
|
|
21
|
+
|
|
22
|
+
for (const line of diffText.split("\n")) {
|
|
23
|
+
if (line.startsWith("@@")) {
|
|
24
|
+
const match = /@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line);
|
|
25
|
+
oldLine = match ? Number(match[1]) : 0;
|
|
26
|
+
newLine = match ? Number(match[2]) : 0;
|
|
27
|
+
rows.push({ kind: "hunk", text: line, oldLine: null, newLine: null });
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (line.startsWith("diff --git") || line.startsWith("index ") || line.startsWith("--- ") || line.startsWith("+++ ")) {
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (line.startsWith("+") && !line.startsWith("+++")) {
|
|
36
|
+
rows.push({ kind: "add", text: line.slice(1), oldLine: null, newLine });
|
|
37
|
+
newLine += 1;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (line.startsWith("-") && !line.startsWith("---")) {
|
|
42
|
+
rows.push({ kind: "delete", text: line.slice(1), oldLine, newLine: null });
|
|
43
|
+
oldLine += 1;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (line.startsWith(" ")) {
|
|
48
|
+
rows.push({ kind: "context", text: line.slice(1), oldLine, newLine });
|
|
49
|
+
oldLine += 1;
|
|
50
|
+
newLine += 1;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
rows.push({ kind: "meta", text: line, oldLine: null, newLine: null });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return rows;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function fullFileRows(content: string): DiffRow[] {
|
|
61
|
+
return content.split("\n").map((line, index) => ({
|
|
62
|
+
kind: "context",
|
|
63
|
+
text: line,
|
|
64
|
+
oldLine: index + 1,
|
|
65
|
+
newLine: index + 1,
|
|
66
|
+
}));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function threadMap(threads: Thread[]): Map<number, ThreadMarker[]> {
|
|
70
|
+
const map = new Map<number, ThreadMarker[]>();
|
|
71
|
+
for (const thread of threads) {
|
|
72
|
+
if (thread.currentLine <= 0) {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const preview = thread.firstComment.split("\n")[0] ?? "";
|
|
77
|
+
const marker: ThreadMarker = { thread, preview };
|
|
78
|
+
const existing = map.get(thread.currentLine) ?? [];
|
|
79
|
+
existing.push(marker);
|
|
80
|
+
map.set(thread.currentLine, existing);
|
|
81
|
+
}
|
|
82
|
+
return map;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function visibleWindow<T>(items: T[], cursor: number, height: number): { start: number; end: number; items: T[] } {
|
|
86
|
+
if (items.length <= height) {
|
|
87
|
+
return { start: 0, end: items.length, items };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const half = Math.floor(height / 2);
|
|
91
|
+
let start = Math.max(0, cursor - half);
|
|
92
|
+
let end = start + height;
|
|
93
|
+
if (end > items.length) {
|
|
94
|
+
end = items.length;
|
|
95
|
+
start = Math.max(0, end - height);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return { start, end, items: items.slice(start, end) };
|
|
99
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import pc from "picocolors";
|
|
2
|
+
import { fg, type RGB, type Theme } from "./theme";
|
|
3
|
+
|
|
4
|
+
interface SyntaxColors {
|
|
5
|
+
keyword: RGB;
|
|
6
|
+
string: RGB;
|
|
7
|
+
comment: RGB;
|
|
8
|
+
number: RGB;
|
|
9
|
+
type: RGB;
|
|
10
|
+
fn: RGB;
|
|
11
|
+
property: RGB;
|
|
12
|
+
punctuation: RGB;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const palettes: Record<string, SyntaxColors> = {
|
|
16
|
+
dark: {
|
|
17
|
+
keyword: [198, 120, 221],
|
|
18
|
+
string: [152, 195, 121],
|
|
19
|
+
comment: [92, 99, 112],
|
|
20
|
+
number: [209, 154, 102],
|
|
21
|
+
type: [86, 182, 194],
|
|
22
|
+
fn: [97, 175, 239],
|
|
23
|
+
property: [171, 178, 191],
|
|
24
|
+
punctuation: [140, 140, 140],
|
|
25
|
+
},
|
|
26
|
+
catppuccin: {
|
|
27
|
+
keyword: [203, 166, 247],
|
|
28
|
+
string: [166, 227, 161],
|
|
29
|
+
comment: [108, 112, 134],
|
|
30
|
+
number: [250, 179, 135],
|
|
31
|
+
type: [148, 226, 213],
|
|
32
|
+
fn: [137, 180, 250],
|
|
33
|
+
property: [205, 214, 244],
|
|
34
|
+
punctuation: [147, 153, 178],
|
|
35
|
+
},
|
|
36
|
+
nord: {
|
|
37
|
+
keyword: [180, 142, 173],
|
|
38
|
+
string: [163, 190, 140],
|
|
39
|
+
comment: [76, 86, 106],
|
|
40
|
+
number: [208, 135, 112],
|
|
41
|
+
type: [143, 188, 187],
|
|
42
|
+
fn: [136, 192, 208],
|
|
43
|
+
property: [216, 222, 233],
|
|
44
|
+
punctuation: [178, 184, 196],
|
|
45
|
+
},
|
|
46
|
+
gruvbox: {
|
|
47
|
+
keyword: [211, 134, 155],
|
|
48
|
+
string: [184, 187, 38],
|
|
49
|
+
comment: [146, 131, 116],
|
|
50
|
+
number: [254, 128, 25],
|
|
51
|
+
type: [131, 165, 152],
|
|
52
|
+
fn: [131, 165, 152],
|
|
53
|
+
property: [235, 219, 178],
|
|
54
|
+
punctuation: [168, 153, 132],
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const KEYWORDS = new Set([
|
|
59
|
+
"if", "else", "elif", "for", "while", "do", "switch", "case", "match",
|
|
60
|
+
"break", "continue", "return", "yield", "try", "catch", "except",
|
|
61
|
+
"finally", "throw", "raise", "default", "when",
|
|
62
|
+
"const", "let", "var", "function", "fn", "func", "def", "class", "struct",
|
|
63
|
+
"enum", "type", "interface", "trait", "impl", "module", "mod", "package",
|
|
64
|
+
"namespace", "import", "export", "from", "use", "require", "include",
|
|
65
|
+
"extends", "implements", "abstract", "override",
|
|
66
|
+
"new", "delete", "typeof", "instanceof", "in", "of", "is", "as",
|
|
67
|
+
"async", "await", "static", "readonly", "mut", "pub", "private",
|
|
68
|
+
"protected", "public", "extern", "unsafe", "declare",
|
|
69
|
+
"and", "or", "not", "with", "defer", "go", "select", "chan", "range",
|
|
70
|
+
"true", "false", "null", "nil", "None", "undefined", "void",
|
|
71
|
+
"self", "this", "super", "Self",
|
|
72
|
+
"lambda", "pass", "begin", "end", "rescue", "ensure", "loop",
|
|
73
|
+
"move", "ref", "where", "dyn", "crate", "goto",
|
|
74
|
+
]);
|
|
75
|
+
|
|
76
|
+
function getSyntax(theme: Theme): SyntaxColors {
|
|
77
|
+
return palettes[theme.name] ?? palettes["dark"]!;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const cache = new Map<string, string>();
|
|
81
|
+
let cacheTheme = "";
|
|
82
|
+
|
|
83
|
+
export function clearHighlightCache(): void {
|
|
84
|
+
cache.clear();
|
|
85
|
+
cacheTheme = "";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function highlightLine(line: string, theme: Theme): string {
|
|
89
|
+
if (theme.name !== cacheTheme) {
|
|
90
|
+
cache.clear();
|
|
91
|
+
cacheTheme = theme.name;
|
|
92
|
+
}
|
|
93
|
+
const cached = cache.get(line);
|
|
94
|
+
if (cached !== undefined) return cached;
|
|
95
|
+
const s = getSyntax(theme);
|
|
96
|
+
let out = "";
|
|
97
|
+
let pos = 0;
|
|
98
|
+
const len = line.length;
|
|
99
|
+
|
|
100
|
+
while (pos < len) {
|
|
101
|
+
const ch = line[pos]!;
|
|
102
|
+
|
|
103
|
+
// Line comments: // or --
|
|
104
|
+
if ((ch === "/" && line[pos + 1] === "/") || (ch === "-" && line[pos + 1] === "-")) {
|
|
105
|
+
out += fg(s.comment, line.slice(pos));
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
// Hash comments (only at start-of-content or after whitespace)
|
|
109
|
+
if (ch === "#" && (pos === 0 || line[pos - 1] === " " || line[pos - 1] === "\t")) {
|
|
110
|
+
out += fg(s.comment, line.slice(pos));
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Block comment within a line: /* ... */
|
|
115
|
+
if (ch === "/" && line[pos + 1] === "*") {
|
|
116
|
+
const close = line.indexOf("*/", pos + 2);
|
|
117
|
+
if (close >= 0) {
|
|
118
|
+
out += fg(s.comment, line.slice(pos, close + 2));
|
|
119
|
+
pos = close + 2;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
out += fg(s.comment, line.slice(pos));
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Strings: "...", '...', `...`
|
|
127
|
+
if (ch === '"' || ch === "'" || ch === "`") {
|
|
128
|
+
let end = pos + 1;
|
|
129
|
+
while (end < len) {
|
|
130
|
+
if (line[end] === "\\") { end += 2; continue; }
|
|
131
|
+
if (line[end] === ch) { end++; break; }
|
|
132
|
+
end++;
|
|
133
|
+
}
|
|
134
|
+
out += fg(s.string, line.slice(pos, end));
|
|
135
|
+
pos = end;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Numbers (not preceded by a letter/underscore)
|
|
140
|
+
if (/\d/.test(ch) && (pos === 0 || !/[a-zA-Z_$]/.test(line[pos - 1] ?? ""))) {
|
|
141
|
+
let end = pos;
|
|
142
|
+
if (ch === "0" && pos + 1 < len && /[xXbBoO]/.test(line[pos + 1]!)) {
|
|
143
|
+
end += 2;
|
|
144
|
+
while (end < len && /[0-9a-fA-F_]/.test(line[end]!)) end++;
|
|
145
|
+
} else {
|
|
146
|
+
while (end < len && /[0-9_]/.test(line[end]!)) end++;
|
|
147
|
+
if (end < len && line[end] === ".") {
|
|
148
|
+
end++;
|
|
149
|
+
while (end < len && /[0-9_]/.test(line[end]!)) end++;
|
|
150
|
+
}
|
|
151
|
+
if (end < len && /[eE]/.test(line[end]!)) {
|
|
152
|
+
end++;
|
|
153
|
+
if (end < len && /[+\-]/.test(line[end]!)) end++;
|
|
154
|
+
while (end < len && /[0-9_]/.test(line[end]!)) end++;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
out += fg(s.number, line.slice(pos, end));
|
|
158
|
+
pos = end;
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Words: identifiers, keywords, types, function calls, decorators
|
|
163
|
+
if (/[a-zA-Z_$@]/.test(ch)) {
|
|
164
|
+
let end = pos;
|
|
165
|
+
const isDecorator = ch === "@";
|
|
166
|
+
if (isDecorator) end++;
|
|
167
|
+
while (end < len && /[a-zA-Z0-9_$]/.test(line[end]!)) end++;
|
|
168
|
+
const word = line.slice(pos, end);
|
|
169
|
+
const bare = isDecorator ? word.slice(1) : word;
|
|
170
|
+
|
|
171
|
+
if (isDecorator) {
|
|
172
|
+
out += fg(s.keyword, word);
|
|
173
|
+
} else if (KEYWORDS.has(bare)) {
|
|
174
|
+
out += fg(s.keyword, word);
|
|
175
|
+
} else if (/^[A-Z][a-zA-Z0-9]*$/.test(bare) && bare.length > 1) {
|
|
176
|
+
out += fg(s.type, word);
|
|
177
|
+
} else if (end < len && line[end] === "(") {
|
|
178
|
+
out += fg(s.fn, word);
|
|
179
|
+
} else {
|
|
180
|
+
out += word;
|
|
181
|
+
}
|
|
182
|
+
pos = end;
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Punctuation
|
|
187
|
+
if (/[()[\]{},;.]/.test(ch)) {
|
|
188
|
+
out += fg(s.punctuation, ch);
|
|
189
|
+
pos++;
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Everything else (operators, whitespace) pass through
|
|
194
|
+
out += ch;
|
|
195
|
+
pos++;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
cache.set(line, out);
|
|
199
|
+
return out;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function renderMarkdown(text: string, theme: Theme): string {
|
|
203
|
+
let result = text;
|
|
204
|
+
result = result.replace(/`([^`]+)`/g, (_, c: string) => fg(theme.accent, c));
|
|
205
|
+
result = result.replace(/\*\*(.+?)\*\*/g, (_, b: string) => pc.bold(b));
|
|
206
|
+
result = result.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, (_, i: string) => pc.italic(i));
|
|
207
|
+
return result;
|
|
208
|
+
}
|
package/src/tui/theme.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
export type RGB = readonly [number, number, number];
|
|
2
|
+
|
|
3
|
+
export interface Theme {
|
|
4
|
+
name: string;
|
|
5
|
+
border: string;
|
|
6
|
+
borderFocus: string;
|
|
7
|
+
cursor: RGB;
|
|
8
|
+
selection: RGB;
|
|
9
|
+
accent: RGB;
|
|
10
|
+
add: RGB;
|
|
11
|
+
addBg: RGB;
|
|
12
|
+
del: RGB;
|
|
13
|
+
delBg: RGB;
|
|
14
|
+
hunk: RGB;
|
|
15
|
+
thread: RGB;
|
|
16
|
+
threadBg: RGB;
|
|
17
|
+
staged: RGB;
|
|
18
|
+
warning: RGB;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const dark: Theme = {
|
|
22
|
+
name: "dark",
|
|
23
|
+
border: "gray",
|
|
24
|
+
borderFocus: "blue",
|
|
25
|
+
cursor: [25, 35, 55],
|
|
26
|
+
selection: [20, 55, 110],
|
|
27
|
+
accent: [70, 130, 255],
|
|
28
|
+
add: [80, 200, 80],
|
|
29
|
+
addBg: [18, 30, 18],
|
|
30
|
+
del: [240, 80, 80],
|
|
31
|
+
delBg: [40, 18, 18],
|
|
32
|
+
hunk: [80, 180, 220],
|
|
33
|
+
thread: [180, 120, 220],
|
|
34
|
+
threadBg: [28, 22, 38],
|
|
35
|
+
staged: [80, 200, 80],
|
|
36
|
+
warning: [230, 180, 50],
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const catppuccin: Theme = {
|
|
40
|
+
name: "catppuccin",
|
|
41
|
+
border: "#585b70",
|
|
42
|
+
borderFocus: "#89b4fa",
|
|
43
|
+
cursor: [49, 50, 68],
|
|
44
|
+
selection: [69, 71, 90],
|
|
45
|
+
accent: [137, 180, 250],
|
|
46
|
+
add: [166, 227, 161],
|
|
47
|
+
addBg: [33, 40, 35],
|
|
48
|
+
del: [243, 139, 168],
|
|
49
|
+
delBg: [43, 30, 35],
|
|
50
|
+
hunk: [137, 220, 235],
|
|
51
|
+
thread: [203, 166, 247],
|
|
52
|
+
threadBg: [35, 28, 45],
|
|
53
|
+
staged: [166, 227, 161],
|
|
54
|
+
warning: [249, 226, 175],
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const nord: Theme = {
|
|
58
|
+
name: "nord",
|
|
59
|
+
border: "#4c566a",
|
|
60
|
+
borderFocus: "#88c0d0",
|
|
61
|
+
cursor: [55, 62, 77],
|
|
62
|
+
selection: [67, 76, 94],
|
|
63
|
+
accent: [136, 192, 208],
|
|
64
|
+
add: [163, 190, 140],
|
|
65
|
+
addBg: [48, 56, 50],
|
|
66
|
+
del: [191, 97, 106],
|
|
67
|
+
delBg: [55, 48, 50],
|
|
68
|
+
hunk: [136, 192, 208],
|
|
69
|
+
thread: [180, 142, 173],
|
|
70
|
+
threadBg: [42, 38, 48],
|
|
71
|
+
staged: [163, 190, 140],
|
|
72
|
+
warning: [235, 203, 139],
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const gruvbox: Theme = {
|
|
76
|
+
name: "gruvbox",
|
|
77
|
+
border: "#504945",
|
|
78
|
+
borderFocus: "#d79921",
|
|
79
|
+
cursor: [60, 56, 54],
|
|
80
|
+
selection: [80, 73, 69],
|
|
81
|
+
accent: [215, 153, 33],
|
|
82
|
+
add: [184, 187, 38],
|
|
83
|
+
addBg: [42, 42, 28],
|
|
84
|
+
del: [251, 73, 52],
|
|
85
|
+
delBg: [50, 32, 30],
|
|
86
|
+
hunk: [131, 165, 152],
|
|
87
|
+
thread: [211, 134, 155],
|
|
88
|
+
threadBg: [42, 30, 34],
|
|
89
|
+
staged: [184, 187, 38],
|
|
90
|
+
warning: [250, 189, 47],
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
export const themes: Record<string, Theme> = { dark, catppuccin, nord, gruvbox };
|
|
94
|
+
export const themeNames = Object.keys(themes);
|
|
95
|
+
|
|
96
|
+
export function getTheme(name?: string): Theme {
|
|
97
|
+
if (name && name in themes) return themes[name]!;
|
|
98
|
+
return dark;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function bg(rgb: RGB, text: string): string {
|
|
102
|
+
return `\u001b[48;2;${rgb[0]};${rgb[1]};${rgb[2]}m${text}\u001b[49m`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function fg(rgb: RGB, text: string): string {
|
|
106
|
+
return `\u001b[38;2;${rgb[0]};${rgb[1]};${rgb[2]}m${text}\u001b[39m`;
|
|
107
|
+
}
|