cowork-cli 0.2.8 → 1.0.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 +1 -1
- package/package.json +2 -2
- package/src/configs/config.json +8 -5
- package/src/configs/sys.txt +33 -0
- package/src/engine/client.js +1 -1
- package/src/engine/models/BaseModel.js +12 -16
- package/src/engine/run.js +6 -7
- package/src/engine/tools/askConfirm.js +34 -0
- package/src/engine/tools/askUser.js +21 -51
- package/src/engine/tools/index.js +18 -2
- package/src/utils/configManager.js +4 -4
- package/src/utils/logger.js +25 -14
- package/src/utils/ui.js +556 -42
package/README.md
CHANGED
|
@@ -54,7 +54,7 @@ cwk "Explain the data flow in the engine/ models"
|
|
|
54
54
|
## ✨ Features that Matter
|
|
55
55
|
|
|
56
56
|
- **Zero-Whitespace UI**: High-density terminal output designed for professionals. No fluff, no headers, just data.
|
|
57
|
-
- **Interactive Feedback**: The AI can
|
|
57
|
+
- **Interactive Feedback**: The AI can request clarifications via the `askUser` tool or trigger an interactive `[ Yes ] No` toggle using `askConfirm`.
|
|
58
58
|
- **Smart Discovery**: Built-in `searchText`, `findFile`, and `projectTree` tools that respect your `.gitignore`.
|
|
59
59
|
- **Surgical I/O**: Read entire files or specific line ranges (`readFileChunk`) with automatic binary detection.
|
|
60
60
|
- **Piping Support**: Pipe logs or diffs directly into `cwk` for instant analysis.
|
package/package.json
CHANGED
package/src/configs/config.json
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"accents": {
|
|
3
|
-
"
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
7
|
-
|
|
3
|
+
"main": "#7BA5DA",
|
|
4
|
+
"tool": "#F2CF6E",
|
|
5
|
+
"data": "#C2C6C5",
|
|
6
|
+
"success": "#7AC391",
|
|
7
|
+
"error": "#E07070",
|
|
8
|
+
"dim": "#606060",
|
|
9
|
+
"header": "#A37ACC"
|
|
10
|
+
}
|
|
8
11
|
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# IDENTITY
|
|
2
|
+
You are Cowork (cwk), an interactive CLI tool acting as a Full Engineering Read-Only Analyst.
|
|
3
|
+
|
|
4
|
+
# CORE MANDATES
|
|
5
|
+
- ONLY perform exactly what the user explicitly says.
|
|
6
|
+
- NO unsolicited investigations, side tasks, or extra work of any kind.
|
|
7
|
+
- Provide accurate data with absolute minimum token usage unless "deep work" is requested.
|
|
8
|
+
|
|
9
|
+
# CONSTRAINTS (Terminal Environment)
|
|
10
|
+
- NO conversational filler, pleasantries, preambles, or postambles.
|
|
11
|
+
- NO Markdown (no *, `, #, or code blocks). NO XML/HTML tags.
|
|
12
|
+
- Output ONLY structured, concise plain text with simple spacing/indentation.
|
|
13
|
+
- Cite code locations using: file_path:line_number.
|
|
14
|
+
|
|
15
|
+
# EXECUTION & STYLE
|
|
16
|
+
- Answer directly without elaboration or explanation.
|
|
17
|
+
- Use fewer than 4 lines (excluding tool use). One-word answers are preferred.
|
|
18
|
+
- Use all tools efficiently (parallel/sequential) to minimize context usage.
|
|
19
|
+
- Verify dependencies and mimic local code style; never assume availability.
|
|
20
|
+
|
|
21
|
+
# EXAMPLES
|
|
22
|
+
user: files in src/?
|
|
23
|
+
assistant:
|
|
24
|
+
src/foo.js
|
|
25
|
+
src/bar.js
|
|
26
|
+
|
|
27
|
+
user: where is foo?
|
|
28
|
+
assistant:
|
|
29
|
+
src/foo.js:12
|
|
30
|
+
|
|
31
|
+
# CONTEXT
|
|
32
|
+
Working directory: ${folder}
|
|
33
|
+
Year: ${year}
|
package/src/engine/client.js
CHANGED
|
@@ -10,7 +10,7 @@ function clientLoader() {
|
|
|
10
10
|
const config = loadConfig();
|
|
11
11
|
|
|
12
12
|
if (!validateConfig(config)) {
|
|
13
|
-
throw new Error("Configuration missing or invalid. Please configure your ~/.env file (run '
|
|
13
|
+
throw new Error("Configuration missing or invalid. Please configure your ~/.env file (run 'cwk --help' for details).");
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
// Normalize baseURL: remove trailing slashes as the SDK appends paths starting with /
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { toolDefinitions, dispatchTool } from '../tools/index.js';
|
|
2
2
|
import { logger } from '../../utils/logger.js';
|
|
3
|
-
import {
|
|
3
|
+
import { ui } from '../../utils/ui.js';
|
|
4
4
|
import { outputFormatted } from '../../utils/outputFormatter.js';
|
|
5
5
|
|
|
6
6
|
/**
|
|
@@ -46,9 +46,9 @@ export default class BaseModel {
|
|
|
46
46
|
turn++;
|
|
47
47
|
|
|
48
48
|
try {
|
|
49
|
-
|
|
49
|
+
ui.think();
|
|
50
50
|
const response = await this._getCompletion();
|
|
51
|
-
|
|
51
|
+
ui.stop();
|
|
52
52
|
|
|
53
53
|
const message = response.choices[0].message;
|
|
54
54
|
|
|
@@ -71,7 +71,7 @@ export default class BaseModel {
|
|
|
71
71
|
await this._processToolCalls(message.tool_calls);
|
|
72
72
|
|
|
73
73
|
} catch (err) {
|
|
74
|
-
|
|
74
|
+
ui.stop();
|
|
75
75
|
// Deep error logging for API failures
|
|
76
76
|
if (err.status) {
|
|
77
77
|
logger.error(`[API Error] Status: ${err.status}`);
|
|
@@ -146,9 +146,9 @@ export default class BaseModel {
|
|
|
146
146
|
const jitter = Math.random() * 500;
|
|
147
147
|
const finalDelay = delay + jitter;
|
|
148
148
|
|
|
149
|
-
|
|
149
|
+
ui.update(`Error ${err.status}. Retrying in ${(finalDelay/1000).toFixed(1)}s`);
|
|
150
150
|
await new Promise(resolve => setTimeout(resolve, finalDelay));
|
|
151
|
-
|
|
151
|
+
ui.update('Thinking');
|
|
152
152
|
continue;
|
|
153
153
|
}
|
|
154
154
|
throw err;
|
|
@@ -194,25 +194,21 @@ export default class BaseModel {
|
|
|
194
194
|
else if (name === 'readFileChunk') displayArg = `${args.filePath} [L${args.startLine}-${args.endLine}]`;
|
|
195
195
|
else displayArg = args.url || args.filePath || args.dirPath || args.path || args.pattern || JSON.stringify(args);
|
|
196
196
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
// to maintain terminal focus and avoid conflicts with the global spinner.
|
|
201
|
-
if (name !== 'askUser') {
|
|
202
|
-
logger.secondary(`[${label}] ${displayStr}`);
|
|
203
|
-
spinner.start(`[${label}] working`);
|
|
197
|
+
// ui.start() handles terminal-aware truncation internally.
|
|
198
|
+
if (name !== 'askUser' && name !== 'askConfirm') {
|
|
199
|
+
ui.start(label, displayArg);
|
|
204
200
|
}
|
|
205
201
|
|
|
206
202
|
const result = await dispatchTool(name, args);
|
|
207
203
|
|
|
208
|
-
if (name !== 'askUser') {
|
|
209
|
-
|
|
204
|
+
if (name !== 'askUser' && name !== 'askConfirm') {
|
|
205
|
+
ui.stop();
|
|
210
206
|
}
|
|
211
207
|
|
|
212
208
|
this.addMessage('tool', result, { tool_call_id: toolCall.id });
|
|
213
209
|
|
|
214
210
|
} catch (err) {
|
|
215
|
-
|
|
211
|
+
ui.stop();
|
|
216
212
|
const errorMsg = err.message;
|
|
217
213
|
logger.error(`[FAILED] ${name}: ${errorMsg}`);
|
|
218
214
|
|
package/src/engine/run.js
CHANGED
|
@@ -6,7 +6,6 @@ import DefaultModel from './models/default.js';
|
|
|
6
6
|
import GeminiModel from './models/gemini.js';
|
|
7
7
|
|
|
8
8
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
9
|
-
const CONFIG_PATH = path.join(__dirname, '../configs/config.json');
|
|
10
9
|
|
|
11
10
|
/**
|
|
12
11
|
* Executes a chat completion query using the appropriate model handler.
|
|
@@ -21,17 +20,17 @@ export default async function runQuery(client, config, query) {
|
|
|
21
20
|
}
|
|
22
21
|
|
|
23
22
|
try {
|
|
24
|
-
// 1. Load and format system prompt from internal
|
|
23
|
+
// 1. Load and format system prompt from internal sys.txt
|
|
25
24
|
let systemPrompt = null;
|
|
26
25
|
try {
|
|
27
|
-
const
|
|
28
|
-
if (
|
|
29
|
-
systemPrompt =
|
|
26
|
+
const promptPath = path.join(__dirname, '../configs/sys.txt');
|
|
27
|
+
if (fs.existsSync(promptPath)) {
|
|
28
|
+
systemPrompt = fs.readFileSync(promptPath, 'utf8')
|
|
30
29
|
.replace('${folder}', process.cwd())
|
|
31
|
-
.replace(
|
|
30
|
+
.replace(/\${year}/g, new Date().getFullYear());
|
|
32
31
|
}
|
|
33
32
|
} catch (e) {
|
|
34
|
-
// Fallback if
|
|
33
|
+
// Fallback if prompt is missing - proceed without system prompt
|
|
35
34
|
}
|
|
36
35
|
|
|
37
36
|
const isGemini = config.model_type.toLowerCase() === 'gemini';
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { ui } from '../../utils/ui.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* askConfirm tool — asks the user a yes/no question via the terminal.
|
|
5
|
+
*
|
|
6
|
+
* Accepted inputs (case-insensitive):
|
|
7
|
+
* yes → y, yes
|
|
8
|
+
* no → n, no
|
|
9
|
+
* cancel → Ctrl+C (treated as no, with dismissed flag)
|
|
10
|
+
*
|
|
11
|
+
* Any other input is silently erased and the prompt re-appears.
|
|
12
|
+
*
|
|
13
|
+
* @param {Object} args
|
|
14
|
+
* @param {string} args.question The yes/no question to ask.
|
|
15
|
+
* @returns {Promise<string>} JSON: { confirmed } or { confirmed, dismissed }
|
|
16
|
+
*/
|
|
17
|
+
export default async function askConfirm({ question }) {
|
|
18
|
+
// 1. Input validation
|
|
19
|
+
if (!question || typeof question !== 'string' || question.trim().length === 0) {
|
|
20
|
+
return JSON.stringify({ error: "'question' must be a non-empty string.", dismissed: true });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// 2. TTY guard — cannot prompt in pipes or CI environments
|
|
24
|
+
if (!process.stdin.isTTY) {
|
|
25
|
+
return JSON.stringify({
|
|
26
|
+
error: 'stdin is not a TTY. Cannot prompt in non-interactive environments.',
|
|
27
|
+
dismissed: true,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// 3. Delegate entirely to UIEngine — it owns all terminal state
|
|
32
|
+
const result = await ui.confirm(question);
|
|
33
|
+
return JSON.stringify(result);
|
|
34
|
+
}
|
|
@@ -1,62 +1,32 @@
|
|
|
1
|
-
import
|
|
2
|
-
import readline from 'readline';
|
|
3
|
-
import { stdin as input, stdout as output } from 'process';
|
|
4
|
-
import { formatSecondary } from '../../utils/logger.js';
|
|
1
|
+
import { ui } from '../../utils/ui.js';
|
|
5
2
|
|
|
6
3
|
/**
|
|
7
4
|
* Implementation of the askUser tool.
|
|
8
|
-
*
|
|
9
|
-
*
|
|
5
|
+
* Delegates rendering and input handling to the UIEngine singleton.
|
|
6
|
+
*
|
|
10
7
|
* @param {Object} args
|
|
11
|
-
* @param {string} args.question
|
|
12
|
-
* @returns {Promise<string>}
|
|
8
|
+
* @param {string} args.question The question to ask the user.
|
|
9
|
+
* @returns {Promise<string>} JSON string: { answer } or { error, dismissed }.
|
|
13
10
|
*/
|
|
14
11
|
export default async function askUser({ question }) {
|
|
15
|
-
// 1. Input
|
|
16
|
-
if (!question) {
|
|
17
|
-
return
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
if (typeof question !== 'string' || question.trim().length === 0) {
|
|
21
|
-
return "Error: 'question' must be a non-empty string.";
|
|
12
|
+
// 1. Input validation
|
|
13
|
+
if (!question || typeof question !== 'string' || question.trim().length === 0) {
|
|
14
|
+
return JSON.stringify({ error: "'question' must be a non-empty string.", dismissed: true });
|
|
22
15
|
}
|
|
23
16
|
|
|
24
|
-
// 2. TTY
|
|
25
|
-
if (!
|
|
26
|
-
return
|
|
17
|
+
// 2. TTY check
|
|
18
|
+
if (!process.stdin.isTTY) {
|
|
19
|
+
return JSON.stringify({ error: 'stdin is not a TTY. Cannot prompt in non-interactive environments.', dismissed: true });
|
|
27
20
|
}
|
|
28
21
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
const doAsk = async () => {
|
|
40
|
-
try {
|
|
41
|
-
const answer = await rl.question(formatSecondary(`[asking] ${question} -> `));
|
|
42
|
-
const trimmed = answer.trim();
|
|
43
|
-
|
|
44
|
-
if (trimmed.length === 0) {
|
|
45
|
-
// Move cursor up and clear line reliably using Node's readline methods
|
|
46
|
-
readline.moveCursor(process.stdout, 0, -1);
|
|
47
|
-
readline.clearLine(process.stdout, 0);
|
|
48
|
-
readline.cursorTo(process.stdout, 0);
|
|
49
|
-
doAsk();
|
|
50
|
-
} else {
|
|
51
|
-
rl.close();
|
|
52
|
-
resolve(JSON.stringify({ answer: trimmed }));
|
|
53
|
-
}
|
|
54
|
-
} catch (err) {
|
|
55
|
-
rl.close();
|
|
56
|
-
resolve(JSON.stringify({ error: `System Error: ${err.message}`, dismissed: true }));
|
|
57
|
-
}
|
|
58
|
-
};
|
|
59
|
-
|
|
60
|
-
doAsk();
|
|
61
|
-
});
|
|
22
|
+
try {
|
|
23
|
+
const answer = await ui.ask(question);
|
|
24
|
+
return JSON.stringify({ answer });
|
|
25
|
+
} catch (err) {
|
|
26
|
+
// ui.ask() rejects with { cancelled: true } on SIGINT, or an Error otherwise.
|
|
27
|
+
if (err && err.cancelled) {
|
|
28
|
+
return JSON.stringify({ error: 'User cancelled the request.', dismissed: true });
|
|
29
|
+
}
|
|
30
|
+
return JSON.stringify({ error: `System Error: ${err.message}`, dismissed: true });
|
|
31
|
+
}
|
|
62
32
|
}
|
|
@@ -8,6 +8,7 @@ import listTools from './listTools.js';
|
|
|
8
8
|
import findFile from './findFile.js';
|
|
9
9
|
import findDir from './findDir.js';
|
|
10
10
|
import askUser from './askUser.js';
|
|
11
|
+
import askConfirm from './askConfirm.js';
|
|
11
12
|
|
|
12
13
|
export const toolDefinitions = [
|
|
13
14
|
{
|
|
@@ -148,7 +149,7 @@ export const toolDefinitions = [
|
|
|
148
149
|
type: "function",
|
|
149
150
|
function: {
|
|
150
151
|
name: "askUser",
|
|
151
|
-
description: "Ask the user
|
|
152
|
+
description: "Ask the user an open-ended question via the terminal and get a free-text response.",
|
|
152
153
|
parameters: {
|
|
153
154
|
type: "object",
|
|
154
155
|
properties: {
|
|
@@ -157,6 +158,20 @@ export const toolDefinitions = [
|
|
|
157
158
|
required: ["question"]
|
|
158
159
|
}
|
|
159
160
|
}
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
type: "function",
|
|
164
|
+
function: {
|
|
165
|
+
name: "askConfirm",
|
|
166
|
+
description: "Ask the user a yes/no confirmation question. Use this instead of askUser when only a boolean decision is needed. Returns { confirmed: true } for yes, { confirmed: false } for no, and { confirmed: false, dismissed: true } if the user cancels with Ctrl+C.",
|
|
167
|
+
parameters: {
|
|
168
|
+
type: "object",
|
|
169
|
+
properties: {
|
|
170
|
+
question: { type: "string", description: "The yes/no question to ask the user." }
|
|
171
|
+
},
|
|
172
|
+
required: ["question"]
|
|
173
|
+
}
|
|
174
|
+
}
|
|
160
175
|
}
|
|
161
176
|
];
|
|
162
177
|
|
|
@@ -170,7 +185,8 @@ const toolImplementations = {
|
|
|
170
185
|
listTools,
|
|
171
186
|
findFile,
|
|
172
187
|
findDir,
|
|
173
|
-
askUser
|
|
188
|
+
askUser,
|
|
189
|
+
askConfirm
|
|
174
190
|
};
|
|
175
191
|
|
|
176
192
|
/**
|
|
@@ -17,10 +17,10 @@ export const loadConfig = () => {
|
|
|
17
17
|
const envConfig = dotenv.parse(content);
|
|
18
18
|
|
|
19
19
|
const config = {
|
|
20
|
-
model_name: envConfig.CWK_MODEL_NAME || envConfig.
|
|
21
|
-
model_url: envConfig.CWK_MODEL_URL || envConfig.
|
|
22
|
-
model_api_key: envConfig.CWK_MODEL_API_KEY || envConfig.
|
|
23
|
-
model_type: envConfig.CWK_MODEL_TYPE || envConfig.
|
|
20
|
+
model_name: envConfig.CWK_MODEL_NAME || envConfig.MODEL_NAME,
|
|
21
|
+
model_url: envConfig.CWK_MODEL_URL || envConfig.MODEL_URL,
|
|
22
|
+
model_api_key: envConfig.CWK_MODEL_API_KEY || envConfig.MODEL_API_KEY,
|
|
23
|
+
model_type: envConfig.CWK_MODEL_TYPE || envConfig.MODEL_TYPE
|
|
24
24
|
};
|
|
25
25
|
|
|
26
26
|
// Remove undefined/empty values
|
package/src/utils/logger.js
CHANGED
|
@@ -9,12 +9,15 @@ let config;
|
|
|
9
9
|
try {
|
|
10
10
|
config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
11
11
|
} catch (e) {
|
|
12
|
-
// Fallback config if file is missing or invalid
|
|
13
12
|
config = {
|
|
14
13
|
accents: {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
14
|
+
main: '#7BA5DA',
|
|
15
|
+
tool: '#F2CF6E',
|
|
16
|
+
data: '#C2C6C5',
|
|
17
|
+
success: '#7AC391',
|
|
18
|
+
error: '#E07070',
|
|
19
|
+
dim: '#606060',
|
|
20
|
+
header: '#A37ACC',
|
|
18
21
|
}
|
|
19
22
|
};
|
|
20
23
|
}
|
|
@@ -26,20 +29,28 @@ function hexToAnsi(hex) {
|
|
|
26
29
|
return `\x1b[38;2;${r};${g};${b}m`;
|
|
27
30
|
}
|
|
28
31
|
|
|
32
|
+
const reset = '\x1b[0m';
|
|
33
|
+
|
|
29
34
|
const colors = {
|
|
30
|
-
main:
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
35
|
+
main: hexToAnsi(config.accents.main),
|
|
36
|
+
tool: hexToAnsi(config.accents.tool),
|
|
37
|
+
data: hexToAnsi(config.accents.data),
|
|
38
|
+
success: hexToAnsi(config.accents.success),
|
|
39
|
+
error: hexToAnsi(config.accents.error),
|
|
40
|
+
dim: hexToAnsi(config.accents.dim),
|
|
41
|
+
header: hexToAnsi(config.accents.header),
|
|
34
42
|
};
|
|
35
43
|
|
|
36
|
-
export const formatMain
|
|
37
|
-
export const formatSecondary = (text) => `${colors.
|
|
38
|
-
export const formatNormal
|
|
44
|
+
export const formatMain = (text) => `${colors.main}${text}${reset}`;
|
|
45
|
+
export const formatSecondary = (text) => `${colors.tool}${text}${reset}`;
|
|
46
|
+
export const formatNormal = (text) => `${colors.data}${text}${reset}`;
|
|
47
|
+
export const formatError = (text) => `${colors.error}${text}${reset}`;
|
|
48
|
+
export const formatDim = (text) => `${colors.dim}${text}${reset}`;
|
|
49
|
+
export const formatHeader = (text) => `${colors.header}${text}${reset}`;
|
|
39
50
|
|
|
40
51
|
export const logger = {
|
|
41
|
-
main:
|
|
52
|
+
main: (msg) => console.log(formatMain(msg)),
|
|
42
53
|
secondary: (msg) => console.log(formatSecondary(msg)),
|
|
43
|
-
normal:
|
|
44
|
-
error:
|
|
54
|
+
normal: (msg) => console.log(formatNormal(msg)),
|
|
55
|
+
error: (msg) => console.error(formatError(msg)),
|
|
45
56
|
};
|
package/src/utils/ui.js
CHANGED
|
@@ -1,68 +1,582 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createInterface } from 'node:readline';
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Design tokens
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
|
|
7
|
+
const COLORS = {
|
|
8
|
+
main: [123, 165, 218], // #7BA5DA – blue (chrome, glyphs, parens)
|
|
9
|
+
tool: [242, 207, 110], // #F2CF6E – amber (label / tool name)
|
|
10
|
+
data: [194, 198, 197], // #C2C6C5 – silver (args, data)
|
|
11
|
+
success: [122, 195, 145], // #7AC391 – green (● on stop)
|
|
12
|
+
error: [224, 112, 112], // #E07070 – red (● on fail)
|
|
13
|
+
dim: [ 96, 96, 96], // #606060 – grey (dim annotations)
|
|
14
|
+
header: [163, 122, 204], // #A37ACC – purple (● header dot)
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const THOUGHTS = [
|
|
18
|
+
'Thinking...', 'Brewing...', 'Grooming...', 'Analyzing...',
|
|
19
|
+
'Investigating...', 'Processing...', 'Synthesizing...', 'Exploring...',
|
|
20
|
+
'Mapping...', 'Reasoning...', 'Meditating...', 'Computing...',
|
|
21
|
+
'Crawling...', 'Scanning...',
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const GLYPHS = {
|
|
25
|
+
header: '●',
|
|
26
|
+
dot: '●', // color-coded: green = stop, red = fail
|
|
27
|
+
ask: '◇',
|
|
28
|
+
input: '>',
|
|
29
|
+
spinner: ['⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷'],
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// State constants
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
const STATE = Object.freeze({
|
|
37
|
+
IDLE: 'IDLE',
|
|
38
|
+
SPINNING: 'SPINNING',
|
|
39
|
+
THINKING: 'THINKING',
|
|
40
|
+
ASKING: 'ASKING',
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// UIEngine
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
2
46
|
|
|
3
47
|
/**
|
|
4
|
-
*
|
|
48
|
+
* UIEngine – State-Based Reactive Terminal Interface.
|
|
49
|
+
*
|
|
50
|
+
* State machine:
|
|
51
|
+
*
|
|
52
|
+
* IDLE ──start()──▶ SPINNING
|
|
53
|
+
* IDLE ──think()──▶ THINKING
|
|
54
|
+
* IDLE ──ask()───▶ ASKING
|
|
55
|
+
* SPINNING/THINKING ──stop()──▶ IDLE (● green)
|
|
56
|
+
* SPINNING/THINKING ──fail()──▶ IDLE (● red)
|
|
57
|
+
* ASKING ──resolve/cancel──▶ IDLE
|
|
58
|
+
*
|
|
59
|
+
* Reactive renderer:
|
|
60
|
+
* _vdom tracks { frame, label, data } currently on screen.
|
|
61
|
+
* Each tick diffs next values against _vdom and calls only the
|
|
62
|
+
* narrowest patch operation needed — no full-line clears on animation ticks.
|
|
63
|
+
*
|
|
64
|
+
* Patch cost per tick (SPINNING, no update):
|
|
65
|
+
* \r + 1 colored char ≈ 22 bytes (no clear)
|
|
66
|
+
*
|
|
67
|
+
* Public API:
|
|
68
|
+
* ui.start(label, data?) → SPINNING
|
|
69
|
+
* ui.think() → THINKING (rotating thought words)
|
|
70
|
+
* ui.update(data) – swap data field in-place
|
|
71
|
+
* ui.stop(msg?) → IDLE (● green)
|
|
72
|
+
* ui.fail(msg?) → IDLE (● red)
|
|
73
|
+
* ui.ask(question) → Promise<string>
|
|
74
|
+
* ui.log(text) – safe from any state
|
|
75
|
+
* ui.header(title) – safe from any state
|
|
76
|
+
* ui.footer(secs) – safe from any state
|
|
77
|
+
* ui.cleanup() – restore cursor, safe from any state
|
|
78
|
+
* ui.state – read-only current state string
|
|
5
79
|
*/
|
|
6
|
-
export class
|
|
80
|
+
export class UIEngine {
|
|
7
81
|
constructor() {
|
|
8
|
-
this.
|
|
9
|
-
this.
|
|
10
|
-
this.
|
|
11
|
-
this.
|
|
12
|
-
this.
|
|
82
|
+
this._stream = process.stdout;
|
|
83
|
+
this._state = STATE.IDLE;
|
|
84
|
+
this._timer = null;
|
|
85
|
+
this._frameIdx = 0;
|
|
86
|
+
this._thoughtIdx = 0;
|
|
87
|
+
|
|
88
|
+
// What the spinner is currently representing
|
|
89
|
+
this._ctx = { label: '', data: '' };
|
|
90
|
+
|
|
91
|
+
// What is actually rendered on the terminal right now
|
|
92
|
+
// null = field not yet on screen / cleared
|
|
93
|
+
this._vdom = { frame: null, label: null, data: null };
|
|
94
|
+
|
|
95
|
+
// Re-render on resize — layout metrics change with terminal width
|
|
96
|
+
this._stream.on('resize', () => {
|
|
97
|
+
if (this._state === STATE.SPINNING || this._state === STATE.THINKING) {
|
|
98
|
+
this._paint(); // full repaint: truncation bounds changed
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// -------------------------------------------------------------------------
|
|
104
|
+
// Public state getter
|
|
105
|
+
// -------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
/** @returns {'IDLE'|'SPINNING'|'THINKING'|'ASKING'} */
|
|
108
|
+
get state() { return this._state; }
|
|
109
|
+
|
|
110
|
+
// -------------------------------------------------------------------------
|
|
111
|
+
// ANSI helpers (pure, no side-effects)
|
|
112
|
+
// -------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
_rgb([r, g, b], text) { return `\x1b[38;2;${r};${g};${b}m${text}\x1b[0m`; }
|
|
115
|
+
_bold(text) { return `\x1b[1m${text}\x1b[0m`; }
|
|
116
|
+
_dim(text) { return `\x1b[2m${text}\x1b[0m`; }
|
|
117
|
+
|
|
118
|
+
// -------------------------------------------------------------------------
|
|
119
|
+
// Terminal control
|
|
120
|
+
// -------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
_w(str) { this._stream.write(str); }
|
|
123
|
+
_clearLine() { this._w('\r\x1b[K'); }
|
|
124
|
+
_hideCursor() { this._w('\x1b[?25l'); }
|
|
125
|
+
_showCursor() { this._w('\x1b[?25h'); }
|
|
126
|
+
_moveUp(n = 1) { this._w(`\x1b[${n}A`); }
|
|
127
|
+
/** Move cursor to visible column n (0-indexed). */
|
|
128
|
+
_toCol(n) { this._w(n > 0 ? `\r\x1b[${n}C` : '\r'); }
|
|
129
|
+
|
|
130
|
+
// -------------------------------------------------------------------------
|
|
131
|
+
// Layout helpers
|
|
132
|
+
// -------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* How many visible chars are available for the data field,
|
|
136
|
+
* given the current terminal width and label length.
|
|
137
|
+
*
|
|
138
|
+
* Line layout (visible columns):
|
|
139
|
+
* frame(1) space(1) label(L) space(1) open(1) data(D) close(1)
|
|
140
|
+
* total = 5 + L + D → D = width - 5 - L - margin(2)
|
|
141
|
+
*/
|
|
142
|
+
_availWidth(label) {
|
|
143
|
+
const w = this._stream.columns || 80;
|
|
144
|
+
return Math.max(0, w - 7 - label.length);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Column where '(' starts = frame(1) + space(1) + label(L) + space(1)
|
|
149
|
+
* = 3 + L
|
|
150
|
+
*/
|
|
151
|
+
_parenCol(label) { return 3 + label.length; }
|
|
152
|
+
|
|
153
|
+
// -------------------------------------------------------------------------
|
|
154
|
+
// Smart truncation
|
|
155
|
+
// -------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Truncate to maxWidth visible chars.
|
|
159
|
+
* Paths: prefer …/parent/file → …/file → …tail.
|
|
160
|
+
* Other: tail-ellipsis.
|
|
161
|
+
*/
|
|
162
|
+
_truncate(text, maxWidth) {
|
|
163
|
+
if (!text || text.length <= maxWidth) return text;
|
|
164
|
+
if (maxWidth <= 2) return '…';
|
|
165
|
+
|
|
166
|
+
if (text.includes('/')) {
|
|
167
|
+
const parts = text.split('/');
|
|
168
|
+
const file = parts.pop();
|
|
169
|
+
const par = parts.pop() || '';
|
|
170
|
+
|
|
171
|
+
const wp = `…/${par}/${file}`;
|
|
172
|
+
if (wp.length <= maxWidth) return wp;
|
|
173
|
+
|
|
174
|
+
const wf = `…/${file}`;
|
|
175
|
+
if (wf.length <= maxWidth) return wf;
|
|
176
|
+
|
|
177
|
+
return `…${file.slice(-(maxWidth - 1))}`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return text.slice(0, maxWidth - 1) + '…';
|
|
13
181
|
}
|
|
14
182
|
|
|
183
|
+
// -------------------------------------------------------------------------
|
|
184
|
+
// Patch operations — surgical cursor-positioned writes, no full-line clears
|
|
185
|
+
// -------------------------------------------------------------------------
|
|
186
|
+
|
|
15
187
|
/**
|
|
16
|
-
*
|
|
188
|
+
* Overwrite only the spinner frame character at col 0.
|
|
189
|
+
* Cost: \r + ~22 bytes of ANSI + 1 char. Rest of line untouched.
|
|
17
190
|
*/
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
this.text = text;
|
|
22
|
-
this.isSpinning = true;
|
|
23
|
-
this.render();
|
|
24
|
-
this.interval = setInterval(() => {
|
|
25
|
-
this.currentFrame = (this.currentFrame + 1) % this.frames.length;
|
|
26
|
-
this.render();
|
|
27
|
-
}, 400); // Slower interval for dot cycle
|
|
191
|
+
_patchFrame(frame) {
|
|
192
|
+
this._w(`\r${this._rgb(COLORS.main, frame)}`);
|
|
193
|
+
// cursor lands at col 1 — the rest of the line is physically untouched
|
|
28
194
|
}
|
|
29
195
|
|
|
30
196
|
/**
|
|
31
|
-
*
|
|
197
|
+
* Overwrite label and everything to the right of it (col 2 onward).
|
|
198
|
+
* Used when label changes (THINKING rotation) or on initial paint for label region.
|
|
199
|
+
* Returns the truncated data string that was written.
|
|
32
200
|
*/
|
|
33
|
-
|
|
34
|
-
this.
|
|
35
|
-
|
|
201
|
+
_patchFromLabel(label, data) {
|
|
202
|
+
const td = data ? this._truncate(data, this._availWidth(label)) : '';
|
|
203
|
+
const dataStr = td
|
|
204
|
+
? ` ${this._rgb(COLORS.main, '(')}${this._rgb(COLORS.data, td)}${this._rgb(COLORS.main, ')')}`
|
|
205
|
+
: '';
|
|
206
|
+
// go to col 2, clear to EOL, write new label + optional data
|
|
207
|
+
this._w(`\r\x1b[2C\x1b[K${this._rgb(COLORS.tool, label)}${dataStr}`);
|
|
208
|
+
return td;
|
|
36
209
|
}
|
|
37
210
|
|
|
38
211
|
/**
|
|
39
|
-
*
|
|
40
|
-
*
|
|
212
|
+
* Overwrite only the data field (from the '(' position onward).
|
|
213
|
+
* Used when data changes via update() — label and frame untouched.
|
|
214
|
+
* Returns the truncated data string that was written.
|
|
41
215
|
*/
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
216
|
+
_patchFromData(label, data) {
|
|
217
|
+
const td = data ? this._truncate(data, this._availWidth(label)) : '';
|
|
218
|
+
const dataStr = td
|
|
219
|
+
? `${this._rgb(COLORS.main, '(')}${this._rgb(COLORS.data, td)}${this._rgb(COLORS.main, ')')}`
|
|
220
|
+
: '';
|
|
221
|
+
// jump to paren col, clear to EOL, write new data section
|
|
222
|
+
this._toCol(this._parenCol(label));
|
|
223
|
+
this._w(`\x1b[K${dataStr}`);
|
|
224
|
+
return td;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// -------------------------------------------------------------------------
|
|
228
|
+
// Full repaint — only at state transitions or terminal resize
|
|
229
|
+
// -------------------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
_paint() {
|
|
232
|
+
const { label, data } = this._ctx;
|
|
233
|
+
const frame = GLYPHS.spinner[this._frameIdx];
|
|
234
|
+
const td = data ? this._truncate(data, this._availWidth(label)) : '';
|
|
235
|
+
const dataStr = td
|
|
236
|
+
? ` ${this._rgb(COLORS.main, '(')}${this._rgb(COLORS.data, td)}${this._rgb(COLORS.main, ')')}`
|
|
237
|
+
: '';
|
|
238
|
+
|
|
239
|
+
this._clearLine(); // ← one of the very few places \r\x1b[K appears
|
|
240
|
+
this._w(`${this._rgb(COLORS.main, frame)} ${this._rgb(COLORS.tool, label)}${dataStr}`);
|
|
241
|
+
|
|
242
|
+
// Sync virtual DOM to match what's on screen
|
|
243
|
+
this._vdom = { frame, label, data: td };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// -------------------------------------------------------------------------
|
|
247
|
+
// Animation tick — diffed, minimal writes
|
|
248
|
+
// -------------------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
_tick() {
|
|
251
|
+
this._frameIdx = (this._frameIdx + 1) % GLYPHS.spinner.length;
|
|
252
|
+
|
|
253
|
+
const nextFrame = GLYPHS.spinner[this._frameIdx];
|
|
254
|
+
let nextLabel = this._ctx.label;
|
|
255
|
+
|
|
256
|
+
// THINKING: rotate label every 8 ticks (~800 ms at 100 ms interval)
|
|
257
|
+
if (this._state === STATE.THINKING && this._frameIdx % 8 === 0) {
|
|
258
|
+
this._thoughtIdx = (this._thoughtIdx + 1) % THOUGHTS.length;
|
|
259
|
+
nextLabel = THOUGHTS[this._thoughtIdx];
|
|
260
|
+
this._ctx.label = nextLabel;
|
|
46
261
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
262
|
+
|
|
263
|
+
const labelChanged = nextLabel !== this._vdom.label;
|
|
264
|
+
const frameChanged = nextFrame !== this._vdom.frame;
|
|
265
|
+
|
|
266
|
+
if (labelChanged) {
|
|
267
|
+
// Repaint from col 2 onward (label + data)
|
|
268
|
+
const td = this._patchFromLabel(nextLabel, this._ctx.data);
|
|
269
|
+
this._vdom.label = nextLabel;
|
|
270
|
+
this._vdom.data = td; // avail width may have changed with new label length
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (frameChanged) {
|
|
274
|
+
// Cheapest possible update: 1 char at col 0
|
|
275
|
+
this._patchFrame(nextFrame);
|
|
276
|
+
this._vdom.frame = nextFrame;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// -------------------------------------------------------------------------
|
|
281
|
+
// Internal transition helpers
|
|
282
|
+
// -------------------------------------------------------------------------
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Silently clear the active spinner line and stop the timer.
|
|
286
|
+
* No completion line printed. Used when start()/think() replaces
|
|
287
|
+
* an existing spinner, or on cleanup().
|
|
288
|
+
*/
|
|
289
|
+
_abort() {
|
|
290
|
+
if (this._state === STATE.IDLE || this._state === STATE.ASKING) return;
|
|
291
|
+
clearInterval(this._timer);
|
|
292
|
+
this._timer = null;
|
|
293
|
+
this._clearLine();
|
|
294
|
+
this._vdom = { frame: null, label: null, data: null };
|
|
295
|
+
this._state = STATE.IDLE;
|
|
296
|
+
this._showCursor();
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Stop the spinner and commit a styled completion line.
|
|
301
|
+
* @param {string|undefined} msg - override data shown in parens
|
|
302
|
+
* @param {number[]} color - COLORS.success or COLORS.error
|
|
303
|
+
*/
|
|
304
|
+
_commit(msg, color) {
|
|
305
|
+
if (this._state === STATE.IDLE || this._state === STATE.ASKING) return;
|
|
306
|
+
clearInterval(this._timer);
|
|
307
|
+
this._timer = null;
|
|
308
|
+
this._clearLine();
|
|
309
|
+
|
|
310
|
+
const label = this._state === STATE.THINKING ? 'Thought' : this._ctx.label;
|
|
311
|
+
const raw = msg !== undefined ? msg : this._ctx.data;
|
|
312
|
+
const td = raw ? this._truncate(raw, this._availWidth(label)) : '';
|
|
313
|
+
const dataStr = td
|
|
314
|
+
? ` ${this._rgb(COLORS.main, '(')}${this._rgb(COLORS.data, td)}${this._rgb(COLORS.main, ')')}`
|
|
315
|
+
: '';
|
|
316
|
+
|
|
317
|
+
this._w(`${this._rgb(color, GLYPHS.dot)} ${this._rgb(COLORS.tool, label)}${dataStr}\n`);
|
|
318
|
+
this._vdom = { frame: null, label: null, data: null };
|
|
319
|
+
this._state = STATE.IDLE;
|
|
320
|
+
this._showCursor();
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// -------------------------------------------------------------------------
|
|
324
|
+
// Public API
|
|
325
|
+
// -------------------------------------------------------------------------
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Enter SPINNING state with a fixed label.
|
|
329
|
+
* Silently replaces any active spinner.
|
|
330
|
+
*/
|
|
331
|
+
start(label, data = '') {
|
|
332
|
+
this._abort();
|
|
333
|
+
this._ctx = { label, data };
|
|
334
|
+
this._frameIdx = 0;
|
|
335
|
+
this._state = STATE.SPINNING;
|
|
336
|
+
this._hideCursor();
|
|
337
|
+
this._paint();
|
|
338
|
+
this._timer = setInterval(() => this._tick(), 100);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Enter THINKING state — label rotates through THOUGHTS words.
|
|
343
|
+
* Silently replaces any active spinner.
|
|
344
|
+
*/
|
|
345
|
+
think() {
|
|
346
|
+
this._abort();
|
|
347
|
+
this._thoughtIdx = 0;
|
|
348
|
+
this._ctx = { label: THOUGHTS[0], data: '' };
|
|
349
|
+
this._frameIdx = 0;
|
|
350
|
+
this._state = STATE.THINKING;
|
|
351
|
+
this._hideCursor();
|
|
352
|
+
this._paint();
|
|
353
|
+
this._timer = setInterval(() => this._tick(), 100);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Swap the data field of a running spinner without stopping it.
|
|
358
|
+
* Only writes to terminal if the (truncated) value actually changed.
|
|
359
|
+
*/
|
|
360
|
+
update(data) {
|
|
361
|
+
if (this._state !== STATE.SPINNING && this._state !== STATE.THINKING) return;
|
|
362
|
+
const td = data ? this._truncate(data, this._availWidth(this._ctx.label)) : '';
|
|
363
|
+
if (td === this._vdom.data) return; // nothing visible changed
|
|
364
|
+
this._ctx.data = data;
|
|
365
|
+
const written = this._patchFromData(this._ctx.label, data);
|
|
366
|
+
this._vdom.data = written;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Finish the spinner with a green ● and an optional final message.
|
|
371
|
+
* SPINNING/THINKING → IDLE.
|
|
372
|
+
*/
|
|
373
|
+
stop(msg) { this._commit(msg, COLORS.success); }
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Finish the spinner with a red ● and an optional final message.
|
|
377
|
+
* SPINNING/THINKING → IDLE.
|
|
378
|
+
*/
|
|
379
|
+
fail(msg) { this._commit(msg, COLORS.error); }
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Print a line of text without disturbing the active spinner.
|
|
383
|
+
* Safe from any state.
|
|
384
|
+
* @param {string} text Pre-formatted string (may contain ANSI codes).
|
|
385
|
+
*/
|
|
386
|
+
log(text) {
|
|
387
|
+
if (this._state === STATE.SPINNING || this._state === STATE.THINKING) {
|
|
388
|
+
this._clearLine();
|
|
389
|
+
this._w(`${text}\n`);
|
|
390
|
+
this._paint(); // restore spinner below
|
|
51
391
|
} else {
|
|
52
|
-
|
|
392
|
+
this._w(`${text}\n`);
|
|
53
393
|
}
|
|
54
|
-
process.stdout.write('\x1b[?25h'); // Show cursor
|
|
55
|
-
this.currentFrame = 0;
|
|
56
394
|
}
|
|
57
395
|
|
|
58
396
|
/**
|
|
59
|
-
*
|
|
397
|
+
* Print a ● header line. Safe from any state via log().
|
|
398
|
+
*/
|
|
399
|
+
header(title) {
|
|
400
|
+
this.log(
|
|
401
|
+
`${this._rgb(COLORS.header, GLYPHS.header)} ${this._rgb(COLORS.main, this._bold(title.toLowerCase()))}`
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Prompt the user for input with a styled ◇ Question prompt.
|
|
407
|
+
* Auto-stops any active spinner first.
|
|
408
|
+
* Resolves with the trimmed answer; rejects { cancelled: true } on SIGINT.
|
|
409
|
+
* Re-prompts silently on empty input.
|
|
410
|
+
* @param {string} question
|
|
411
|
+
* @returns {Promise<string>}
|
|
412
|
+
*/
|
|
413
|
+
ask(question) {
|
|
414
|
+
this._abort();
|
|
415
|
+
|
|
416
|
+
if (!process.stdin.isTTY) {
|
|
417
|
+
return Promise.reject(new Error('stdin is not a TTY'));
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
this._state = STATE.ASKING;
|
|
421
|
+
|
|
422
|
+
return new Promise((resolve, reject) => {
|
|
423
|
+
const rl = createInterface({ input: process.stdin, output: this._stream });
|
|
424
|
+
|
|
425
|
+
const w = this._stream.columns || 80;
|
|
426
|
+
const avail = Math.max(0, w - 14); // ◇ Ask ( ... )
|
|
427
|
+
const truncQ = this._truncate(question, avail);
|
|
428
|
+
|
|
429
|
+
this._w(
|
|
430
|
+
`${this._rgb(COLORS.main, GLYPHS.ask)} ` +
|
|
431
|
+
`${this._rgb(COLORS.tool, 'Ask')} ` +
|
|
432
|
+
`${this._rgb(COLORS.main, '(')}${this._rgb(COLORS.data, truncQ)}${this._rgb(COLORS.main, ')')}\n`
|
|
433
|
+
);
|
|
434
|
+
|
|
435
|
+
const prompt = `${this._rgb(COLORS.main, this._bold(GLYPHS.input))} `;
|
|
436
|
+
|
|
437
|
+
rl.on('SIGINT', () => {
|
|
438
|
+
rl.close();
|
|
439
|
+
this._showCursor();
|
|
440
|
+
this._state = STATE.IDLE;
|
|
441
|
+
reject({ cancelled: true });
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
const doAsk = () => {
|
|
445
|
+
rl.question(prompt, (raw) => {
|
|
446
|
+
const ans = raw.trim();
|
|
447
|
+
if (!ans) {
|
|
448
|
+
this._moveUp(1);
|
|
449
|
+
this._clearLine();
|
|
450
|
+
doAsk();
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
rl.close();
|
|
454
|
+
this._moveUp(1);
|
|
455
|
+
this._clearLine();
|
|
456
|
+
this._w(`${this._rgb(COLORS.main, this._bold(GLYPHS.input))} ${ans}\n`);
|
|
457
|
+
this._state = STATE.IDLE;
|
|
458
|
+
resolve(ans);
|
|
459
|
+
});
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
doAsk();
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Prompt the user for a yes/no confirmation with an interactive toggle.
|
|
468
|
+
*
|
|
469
|
+
* Behaviour:
|
|
470
|
+
* • Renders a `[ Yes ] No` toggle selector.
|
|
471
|
+
* • Use Arrow Keys, Tab, or Space to toggle. Y/N to select directly.
|
|
472
|
+
* • Enter to confirm.
|
|
473
|
+
* • Ctrl+C (SIGINT) → resolves { confirmed: false, dismissed: true }.
|
|
474
|
+
* • Answer is echoed in green (yes) or red (no).
|
|
475
|
+
*
|
|
476
|
+
* @param {string} question
|
|
477
|
+
* @returns {Promise<{ confirmed: boolean, dismissed?: true }>}
|
|
478
|
+
*/
|
|
479
|
+
confirm(question) {
|
|
480
|
+
this._abort();
|
|
481
|
+
|
|
482
|
+
if (!process.stdin.isTTY) {
|
|
483
|
+
return Promise.resolve({ confirmed: false, dismissed: true });
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
this._state = STATE.ASKING;
|
|
487
|
+
|
|
488
|
+
return new Promise((resolve) => {
|
|
489
|
+
const w = this._stream.columns || 80;
|
|
490
|
+
const avail = Math.max(0, w - 14);
|
|
491
|
+
const truncQ = this._truncate(question, avail);
|
|
492
|
+
|
|
493
|
+
this._w(
|
|
494
|
+
`${this._rgb(COLORS.main, GLYPHS.ask)} ` +
|
|
495
|
+
`${this._rgb(COLORS.tool, 'Ask')} ` +
|
|
496
|
+
`${this._rgb(COLORS.main, '(')}${this._rgb(COLORS.data, truncQ)}${this._rgb(COLORS.main, ')')}\n`
|
|
497
|
+
);
|
|
498
|
+
|
|
499
|
+
let selectedYes = true;
|
|
500
|
+
|
|
501
|
+
const renderToggle = () => {
|
|
502
|
+
this._clearLine();
|
|
503
|
+
const yesLabel = selectedYes ? this._rgb(COLORS.main, this._bold('[ Yes ]')) : this._dim(' Yes ');
|
|
504
|
+
const noLabel = !selectedYes ? this._rgb(COLORS.main, this._bold('[ No ]')) : this._dim(' No ');
|
|
505
|
+
this._w(`${this._rgb(COLORS.main, this._bold(GLYPHS.input))} ${yesLabel} ${noLabel}`);
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
this._hideCursor();
|
|
509
|
+
renderToggle();
|
|
510
|
+
|
|
511
|
+
const onData = (data) => {
|
|
512
|
+
const str = data.toString();
|
|
513
|
+
|
|
514
|
+
// Ctrl+C
|
|
515
|
+
if (str === '\u0003') {
|
|
516
|
+
cleanup();
|
|
517
|
+
this._clearLine();
|
|
518
|
+
this._w(`${this._rgb(COLORS.main, this._bold(GLYPHS.input))} ${this._dim('cancelled')}\n`);
|
|
519
|
+
this._showCursor();
|
|
520
|
+
this._state = STATE.IDLE;
|
|
521
|
+
resolve({ confirmed: false, dismissed: true });
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// Enter
|
|
526
|
+
if (str === '\r' || str === '\n') {
|
|
527
|
+
cleanup();
|
|
528
|
+
this._clearLine();
|
|
529
|
+
const finalColor = selectedYes ? COLORS.success : COLORS.error;
|
|
530
|
+
const finalStr = selectedYes ? 'yes' : 'no';
|
|
531
|
+
this._w(`${this._rgb(COLORS.main, this._bold(GLYPHS.input))} ${this._rgb(finalColor, finalStr)}\n`);
|
|
532
|
+
this._showCursor();
|
|
533
|
+
this._state = STATE.IDLE;
|
|
534
|
+
resolve({ confirmed: selectedYes });
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// Toggle keys (Left, Right, Up, Down, Tab, Space)
|
|
539
|
+
if (str === '\u001b[D' || str === '\u001b[C' || str === '\u001b[A' || str === '\u001b[B' || str === '\t' || str === ' ') {
|
|
540
|
+
selectedYes = !selectedYes;
|
|
541
|
+
renderToggle();
|
|
542
|
+
} else if (str.toLowerCase() === 'y') {
|
|
543
|
+
selectedYes = true;
|
|
544
|
+
renderToggle();
|
|
545
|
+
} else if (str.toLowerCase() === 'n') {
|
|
546
|
+
selectedYes = false;
|
|
547
|
+
renderToggle();
|
|
548
|
+
}
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
const cleanup = () => {
|
|
552
|
+
if (process.stdin.isTTY) {
|
|
553
|
+
process.stdin.setRawMode(false);
|
|
554
|
+
}
|
|
555
|
+
process.stdin.off('data', onData);
|
|
556
|
+
process.stdin.pause();
|
|
557
|
+
};
|
|
558
|
+
|
|
559
|
+
if (process.stdin.isTTY) {
|
|
560
|
+
process.stdin.setRawMode(true);
|
|
561
|
+
}
|
|
562
|
+
process.stdin.resume();
|
|
563
|
+
process.stdin.on('data', onData);
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
footer(duration) {
|
|
569
|
+
this.log(`${this._dim('time')} ${this._rgb(COLORS.main, duration + 's')}`);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* Restore cursor and stop timer. Safe to call from SIGINT / uncaughtException.
|
|
60
574
|
*/
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
process.stdout.write(`\r\x1b[K${formatSecondary(`${this.text}${dots}`)}`);
|
|
575
|
+
cleanup() {
|
|
576
|
+
this._abort();
|
|
577
|
+
this._showCursor();
|
|
65
578
|
}
|
|
66
579
|
}
|
|
67
580
|
|
|
68
|
-
|
|
581
|
+
// Singleton — import { ui } wherever you need the terminal interface.
|
|
582
|
+
export const ui = new UIEngine();
|