@jc20231028/local-code-agent 0.1.0 → 0.1.1

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/src/ui.js CHANGED
@@ -1,281 +1,281 @@
1
- import readline from "node:readline";
2
- import readlinePromises from "node:readline/promises";
3
- import { stdin as input, stdout as output } from "node:process";
4
-
5
- const ANSI = {
6
- reset: "\u001b[0m",
7
- bold: "\u001b[1m",
8
- dim: "\u001b[2m",
9
- cyan: "\u001b[36m",
10
- green: "\u001b[32m",
11
- yellow: "\u001b[33m",
12
- red: "\u001b[31m",
13
- gray: "\u001b[90m"
14
- };
15
-
16
- export async function selectMenu({ title, subtitle = "", headerLines = [], options, footer = "" }) {
17
- if (!isInteractive()) {
18
- throw new Error("Interactive menu requires a TTY.");
19
- }
20
-
21
- const enabledIndexes = options
22
- .map((option, index) => ({ option, index }))
23
- .filter((entry) => !entry.option.disabled)
24
- .map((entry) => entry.index);
25
-
26
- if (enabledIndexes.length === 0) {
27
- throw new Error("Interactive menu has no selectable options.");
28
- }
29
-
30
- let selectedIndex = enabledIndexes[0];
31
-
32
- return new Promise((resolve, reject) => {
33
- readline.emitKeypressEvents(input);
34
- const previousRawMode = typeof input.setRawMode === "function" ? input.isRaw : false;
35
- if (typeof input.setRawMode === "function") {
36
- input.setRawMode(true);
37
- }
38
-
39
- const cleanup = () => {
40
- input.removeListener("keypress", onKeypress);
41
- if (typeof input.setRawMode === "function") {
42
- input.setRawMode(previousRawMode);
43
- }
44
- output.write("\n");
45
- };
46
-
47
- const render = () => {
48
- console.clear();
49
- const lines = [];
50
- lines.push(style(title, "bold"));
51
- if (subtitle) {
52
- lines.push(style(subtitle, "dim"));
53
- }
54
- if (headerLines.length > 0) {
55
- lines.push("");
56
- lines.push(...headerLines);
57
- }
58
- lines.push("");
59
-
60
- for (let index = 0; index < options.length; index += 1) {
61
- const option = options[index];
62
- const focused = index === selectedIndex;
63
- const pointer = focused ? color("> ", "cyan") : " ";
64
- const label = focused ? style(option.label, "bold") : option.label;
65
- const status = color(
66
- option.badgeLabel ?? (option.disabled ? "unavailable" : "ready"),
67
- option.badgeTone ?? (option.disabled ? "red" : "green")
68
- );
69
- lines.push(`${pointer}${label} ${style(`[${status}]`, "dim")}`);
70
-
71
- if (option.description) {
72
- lines.push(` ${style(option.description, option.disabled ? "gray" : "dim")}`);
73
- }
74
-
75
- if (option.hint) {
76
- lines.push(` ${style(option.hint, option.disabled ? "yellow" : "gray")}`);
77
- }
78
-
79
- lines.push("");
80
- }
81
-
82
- if (footer) {
83
- lines.push(style(footer, "dim"));
84
- }
85
-
86
- output.write(lines.join("\n"));
87
- };
88
-
89
- const onKeypress = (_, key) => {
90
- if (!key) {
91
- return;
92
- }
93
-
94
- if (key.ctrl && key.name === "c") {
95
- cleanup();
96
- reject(new Error("Cancelled by user."));
97
- return;
98
- }
99
-
100
- if (key.name === "up") {
101
- selectedIndex = moveSelection(options, enabledIndexes, selectedIndex, -1);
102
- render();
103
- return;
104
- }
105
-
106
- if (key.name === "down") {
107
- selectedIndex = moveSelection(options, enabledIndexes, selectedIndex, 1);
108
- render();
109
- return;
110
- }
111
-
112
- if (key.name === "return") {
113
- const option = options[selectedIndex];
114
- cleanup();
115
- resolve(option.value);
116
- }
117
- };
118
-
119
- input.on("keypress", onKeypress);
120
- render();
121
- });
122
- }
123
-
124
- export function printSplash({ workspace, command }) {
125
- if (!isInteractive()) {
126
- return;
127
- }
128
-
129
- console.log(renderStartupDashboard({ workspace, command }));
130
- }
131
-
132
- export async function withSpinner(message, task) {
133
- if (!isInteractive()) {
134
- return task();
135
- }
136
-
137
- const frames = ["|", "/", "-", "\\"];
138
- let frameIndex = 0;
139
- const render = () => {
140
- output.write(`\r${color(frames[frameIndex], "cyan")} ${message}`);
141
- frameIndex = (frameIndex + 1) % frames.length;
142
- };
143
-
144
- render();
145
- const timer = setInterval(render, 90);
146
-
147
- try {
148
- const result = await task();
149
- clearInterval(timer);
150
- output.write(`\r${color("OK", "green")} ${message}\n`);
151
- return result;
152
- } catch (error) {
153
- clearInterval(timer);
154
- output.write(`\r${color("ERR", "red")} ${message}\n`);
155
- throw error;
156
- }
157
- }
158
-
159
- export async function confirmCommand({ command, args = [], cwd }) {
160
- if (!isInteractive()) {
161
- return false;
162
- }
163
-
164
- const commandLine = [command, ...args].join(" ");
165
- output.write(`\n${style("Model wants to run:", "yellow")} ${commandLine}\n`);
166
- output.write(`${style("Working directory:", "dim")} ${cwd}\n`);
167
-
168
- const rl = readlinePromises.createInterface({ input, output });
169
- try {
170
- const answer = (await rl.question("Allow this command? [y/N]: ")).trim().toLowerCase();
171
- return answer === "y" || answer === "yes";
172
- } finally {
173
- rl.close();
174
- }
175
- }
176
-
177
- export function printNote(message) {
178
- console.log(style(message, "dim"));
179
- }
180
-
181
- export function renderStartupDashboard({
182
- workspace,
183
- command,
184
- lastUsedProvider = "",
185
- lastUsedModel = "",
186
- lastTaskSummary = "",
187
- readyCount,
188
- totalProviders,
189
- readyProviders = [],
190
- recentFiles = []
191
- }) {
192
- const lines = [
193
- style("local-code-agent", "bold"),
194
- style("Local coding CLI for Ollama and LM Studio", "dim"),
195
- style("--------------------------------------------------", "gray"),
196
- `${style("Workspace", "cyan")} : ${workspace}`,
197
- `${style("Command", "cyan")} : ${command}`
198
- ];
199
-
200
- if (lastUsedProvider || lastUsedModel) {
201
- lines.push(`${style("Last used", "cyan")} : ${formatLastUsed(lastUsedProvider, lastUsedModel)}`);
202
- } else {
203
- lines.push(`${style("Last used", "cyan")} : none saved yet`);
204
- }
205
-
206
- if (lastTaskSummary) {
207
- lines.push(`${style("Last task", "cyan")} : ${lastTaskSummary}`);
208
- }
209
-
210
- if (typeof readyCount === "number" && typeof totalProviders === "number") {
211
- lines.push(`${style("Ready now", "cyan")} : ${readyCount}/${totalProviders}`);
212
- }
213
-
214
- if (readyProviders.length > 0) {
215
- lines.push(`${style("Online", "cyan")} : ${readyProviders.join(", ")}`);
216
- }
217
-
218
- if (recentFiles.length > 0) {
219
- lines.push(`${style("Recent files", "cyan")} : ${recentFiles.join(", ")}`);
220
- }
221
-
222
- lines.push("");
223
- return lines.join("\n");
224
- }
225
-
226
- export function renderDiagnostics(title, sections) {
227
- const lines = [style(title, "bold"), ""];
228
-
229
- for (const section of sections) {
230
- lines.push(section.heading);
231
- for (const line of section.lines) {
232
- lines.push(line);
233
- }
234
- lines.push("");
235
- }
236
-
237
- return lines.join("\n").trimEnd();
238
- }
239
-
240
- export function color(text, tone) {
241
- if (!supportsAnsi()) {
242
- return text;
243
- }
244
-
245
- const code = ANSI[tone];
246
- if (!code) {
247
- return text;
248
- }
249
-
250
- return `${code}${text}${ANSI.reset}`;
251
- }
252
-
253
- export function style(text, tone) {
254
- return color(text, tone);
255
- }
256
-
257
- export function supportsAnsi() {
258
- return Boolean(output.isTTY && process.env.TERM !== "dumb");
259
- }
260
-
261
- export function isInteractive() {
262
- return Boolean(input.isTTY && output.isTTY);
263
- }
264
-
265
- function moveSelection(options, enabledIndexes, currentIndex, direction) {
266
- const currentPosition = enabledIndexes.indexOf(currentIndex);
267
- const nextPosition = (currentPosition + direction + enabledIndexes.length) % enabledIndexes.length;
268
- const nextIndex = enabledIndexes[nextPosition];
269
-
270
- if (options[nextIndex]?.disabled) {
271
- return currentIndex;
272
- }
273
-
274
- return nextIndex;
275
- }
276
-
277
- function formatLastUsed(provider, model) {
278
- const savedProvider = provider || "unknown provider";
279
- const savedModel = model || "no model saved";
280
- return `${savedProvider} / ${savedModel}`;
281
- }
1
+ import readline from "node:readline";
2
+ import readlinePromises from "node:readline/promises";
3
+ import { stdin as input, stdout as output } from "node:process";
4
+
5
+ const ANSI = {
6
+ reset: "\u001b[0m",
7
+ bold: "\u001b[1m",
8
+ dim: "\u001b[2m",
9
+ cyan: "\u001b[36m",
10
+ green: "\u001b[32m",
11
+ yellow: "\u001b[33m",
12
+ red: "\u001b[31m",
13
+ gray: "\u001b[90m"
14
+ };
15
+
16
+ export async function selectMenu({ title, subtitle = "", headerLines = [], options, footer = "" }) {
17
+ if (!isInteractive()) {
18
+ throw new Error("Interactive menu requires a TTY.");
19
+ }
20
+
21
+ const enabledIndexes = options
22
+ .map((option, index) => ({ option, index }))
23
+ .filter((entry) => !entry.option.disabled)
24
+ .map((entry) => entry.index);
25
+
26
+ if (enabledIndexes.length === 0) {
27
+ throw new Error("Interactive menu has no selectable options.");
28
+ }
29
+
30
+ let selectedIndex = enabledIndexes[0];
31
+
32
+ return new Promise((resolve, reject) => {
33
+ readline.emitKeypressEvents(input);
34
+ const previousRawMode = typeof input.setRawMode === "function" ? input.isRaw : false;
35
+ if (typeof input.setRawMode === "function") {
36
+ input.setRawMode(true);
37
+ }
38
+
39
+ const cleanup = () => {
40
+ input.removeListener("keypress", onKeypress);
41
+ if (typeof input.setRawMode === "function") {
42
+ input.setRawMode(previousRawMode);
43
+ }
44
+ output.write("\n");
45
+ };
46
+
47
+ const render = () => {
48
+ console.clear();
49
+ const lines = [];
50
+ lines.push(style(title, "bold"));
51
+ if (subtitle) {
52
+ lines.push(style(subtitle, "dim"));
53
+ }
54
+ if (headerLines.length > 0) {
55
+ lines.push("");
56
+ lines.push(...headerLines);
57
+ }
58
+ lines.push("");
59
+
60
+ for (let index = 0; index < options.length; index += 1) {
61
+ const option = options[index];
62
+ const focused = index === selectedIndex;
63
+ const pointer = focused ? color("> ", "cyan") : " ";
64
+ const label = focused ? style(option.label, "bold") : option.label;
65
+ const status = color(
66
+ option.badgeLabel ?? (option.disabled ? "unavailable" : "ready"),
67
+ option.badgeTone ?? (option.disabled ? "red" : "green")
68
+ );
69
+ lines.push(`${pointer}${label} ${style(`[${status}]`, "dim")}`);
70
+
71
+ if (option.description) {
72
+ lines.push(` ${style(option.description, option.disabled ? "gray" : "dim")}`);
73
+ }
74
+
75
+ if (option.hint) {
76
+ lines.push(` ${style(option.hint, option.disabled ? "yellow" : "gray")}`);
77
+ }
78
+
79
+ lines.push("");
80
+ }
81
+
82
+ if (footer) {
83
+ lines.push(style(footer, "dim"));
84
+ }
85
+
86
+ output.write(lines.join("\n"));
87
+ };
88
+
89
+ const onKeypress = (_, key) => {
90
+ if (!key) {
91
+ return;
92
+ }
93
+
94
+ if (key.ctrl && key.name === "c") {
95
+ cleanup();
96
+ reject(new Error("Cancelled by user."));
97
+ return;
98
+ }
99
+
100
+ if (key.name === "up") {
101
+ selectedIndex = moveSelection(options, enabledIndexes, selectedIndex, -1);
102
+ render();
103
+ return;
104
+ }
105
+
106
+ if (key.name === "down") {
107
+ selectedIndex = moveSelection(options, enabledIndexes, selectedIndex, 1);
108
+ render();
109
+ return;
110
+ }
111
+
112
+ if (key.name === "return") {
113
+ const option = options[selectedIndex];
114
+ cleanup();
115
+ resolve(option.value);
116
+ }
117
+ };
118
+
119
+ input.on("keypress", onKeypress);
120
+ render();
121
+ });
122
+ }
123
+
124
+ export function printSplash({ workspace, command }) {
125
+ if (!isInteractive()) {
126
+ return;
127
+ }
128
+
129
+ console.log(renderStartupDashboard({ workspace, command }));
130
+ }
131
+
132
+ export async function withSpinner(message, task) {
133
+ if (!isInteractive()) {
134
+ return task();
135
+ }
136
+
137
+ const frames = ["|", "/", "-", "\\"];
138
+ let frameIndex = 0;
139
+ const render = () => {
140
+ output.write(`\r${color(frames[frameIndex], "cyan")} ${message}`);
141
+ frameIndex = (frameIndex + 1) % frames.length;
142
+ };
143
+
144
+ render();
145
+ const timer = setInterval(render, 90);
146
+
147
+ try {
148
+ const result = await task();
149
+ clearInterval(timer);
150
+ output.write(`\r${color("OK", "green")} ${message}\n`);
151
+ return result;
152
+ } catch (error) {
153
+ clearInterval(timer);
154
+ output.write(`\r${color("ERR", "red")} ${message}\n`);
155
+ throw error;
156
+ }
157
+ }
158
+
159
+ export async function confirmCommand({ command, args = [], cwd }) {
160
+ if (!isInteractive()) {
161
+ return false;
162
+ }
163
+
164
+ const commandLine = [command, ...args].join(" ");
165
+ output.write(`\n${style("Model wants to run:", "yellow")} ${commandLine}\n`);
166
+ output.write(`${style("Working directory:", "dim")} ${cwd}\n`);
167
+
168
+ const rl = readlinePromises.createInterface({ input, output });
169
+ try {
170
+ const answer = (await rl.question("Allow this command? [y/N]: ")).trim().toLowerCase();
171
+ return answer === "y" || answer === "yes";
172
+ } finally {
173
+ rl.close();
174
+ }
175
+ }
176
+
177
+ export function printNote(message) {
178
+ console.log(style(message, "dim"));
179
+ }
180
+
181
+ export function renderStartupDashboard({
182
+ workspace,
183
+ command,
184
+ lastUsedProvider = "",
185
+ lastUsedModel = "",
186
+ lastTaskSummary = "",
187
+ readyCount,
188
+ totalProviders,
189
+ readyProviders = [],
190
+ recentFiles = []
191
+ }) {
192
+ const lines = [
193
+ style("local-code-agent", "bold"),
194
+ style("Local coding CLI for Ollama and LM Studio", "dim"),
195
+ style("--------------------------------------------------", "gray"),
196
+ `${style("Workspace", "cyan")} : ${workspace}`,
197
+ `${style("Command", "cyan")} : ${command}`
198
+ ];
199
+
200
+ if (lastUsedProvider || lastUsedModel) {
201
+ lines.push(`${style("Last used", "cyan")} : ${formatLastUsed(lastUsedProvider, lastUsedModel)}`);
202
+ } else {
203
+ lines.push(`${style("Last used", "cyan")} : none saved yet`);
204
+ }
205
+
206
+ if (lastTaskSummary) {
207
+ lines.push(`${style("Last task", "cyan")} : ${lastTaskSummary}`);
208
+ }
209
+
210
+ if (typeof readyCount === "number" && typeof totalProviders === "number") {
211
+ lines.push(`${style("Ready now", "cyan")} : ${readyCount}/${totalProviders}`);
212
+ }
213
+
214
+ if (readyProviders.length > 0) {
215
+ lines.push(`${style("Online", "cyan")} : ${readyProviders.join(", ")}`);
216
+ }
217
+
218
+ if (recentFiles.length > 0) {
219
+ lines.push(`${style("Recent files", "cyan")} : ${recentFiles.join(", ")}`);
220
+ }
221
+
222
+ lines.push("");
223
+ return lines.join("\n");
224
+ }
225
+
226
+ export function renderDiagnostics(title, sections) {
227
+ const lines = [style(title, "bold"), ""];
228
+
229
+ for (const section of sections) {
230
+ lines.push(section.heading);
231
+ for (const line of section.lines) {
232
+ lines.push(line);
233
+ }
234
+ lines.push("");
235
+ }
236
+
237
+ return lines.join("\n").trimEnd();
238
+ }
239
+
240
+ export function color(text, tone) {
241
+ if (!supportsAnsi()) {
242
+ return text;
243
+ }
244
+
245
+ const code = ANSI[tone];
246
+ if (!code) {
247
+ return text;
248
+ }
249
+
250
+ return `${code}${text}${ANSI.reset}`;
251
+ }
252
+
253
+ export function style(text, tone) {
254
+ return color(text, tone);
255
+ }
256
+
257
+ export function supportsAnsi() {
258
+ return Boolean(output.isTTY && process.env.TERM !== "dumb");
259
+ }
260
+
261
+ export function isInteractive() {
262
+ return Boolean(input.isTTY && output.isTTY);
263
+ }
264
+
265
+ function moveSelection(options, enabledIndexes, currentIndex, direction) {
266
+ const currentPosition = enabledIndexes.indexOf(currentIndex);
267
+ const nextPosition = (currentPosition + direction + enabledIndexes.length) % enabledIndexes.length;
268
+ const nextIndex = enabledIndexes[nextPosition];
269
+
270
+ if (options[nextIndex]?.disabled) {
271
+ return currentIndex;
272
+ }
273
+
274
+ return nextIndex;
275
+ }
276
+
277
+ function formatLastUsed(provider, model) {
278
+ const savedProvider = provider || "unknown provider";
279
+ const savedModel = model || "no model saved";
280
+ return `${savedProvider} / ${savedModel}`;
281
+ }