@synqa/cli 1.1.0 → 1.2.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 +145 -57
- package/bin/synqa.js +0 -0
- package/dist/agent.js +19 -5
- package/dist/api.js +46 -4
- package/dist/commands.js +150 -30
- package/dist/compact.js +4 -1
- package/dist/config.js +52 -9
- package/dist/hooks.js +219 -0
- package/dist/main.js +275 -91
- package/dist/modes.js +20 -0
- package/dist/paths.js +157 -38
- package/dist/prompt.js +381 -0
- package/dist/providers.js +234 -0
- package/dist/sanitize.js +6 -0
- package/dist/select.js +131 -0
- package/dist/statusbar.js +16 -9
- package/dist/theme.js +47 -7
- package/dist/tiers.js +1 -1
- package/dist/tools.js +133 -44
- package/dist/ui.js +152 -72
- package/dist/update.js +71 -0
- package/package.json +1 -1
package/dist/paths.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { closeSync, constants, lstatSync, openSync, realpathSync, writeSync } from "node:fs";
|
|
2
|
+
import { dirname, isAbsolute, join, parse, relative, resolve, sep } from "node:path";
|
|
2
3
|
import { homedir } from "node:os";
|
|
3
|
-
import { basename, dirname, isAbsolute, join, parse, relative, resolve, sep } from "node:path";
|
|
4
4
|
import { configDir } from "./config.js";
|
|
5
5
|
|
|
6
6
|
export class ConfineError extends Error {}
|
|
@@ -62,47 +62,171 @@ export function fold(s ) {
|
|
|
62
62
|
return s.normalize("NFKC").toLowerCase();
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
//
|
|
66
|
-
|
|
65
|
+
// The protected tables. isSensitivePath (file tools) and commandTouchesSecrets (shell) are both
|
|
66
|
+
// built from these, so a name added here is protected everywhere at once.
|
|
67
|
+
// Folders whose contents are credentials, matched as any one path segment.
|
|
68
|
+
export const SENSITIVE_DIRS = [".ssh", ".aws", ".gnupg", ".kube", ".password-store", ".mozilla", ".azure", "keychains"];
|
|
69
|
+
// Runs of path segments that hold credentials, browser profiles or tokens, matched anywhere in the
|
|
70
|
+
// path (folded). Inside the home folder every dot entry and Library is protected anyway (see
|
|
71
|
+
// homeHidden); these runs also protect copies of such folders elsewhere.
|
|
72
|
+
export const SENSITIVE_RUNS = [
|
|
73
|
+
[".config", "synqa"], [".config", "gh"], [".config", "hub"], [".config", "gcloud"], [".config", "op"], [".config", "heroku"],
|
|
74
|
+
[".config", "google-chrome"], [".config", "chromium"], [".config", "bravesoftware"], [".config", "microsoft-edge"],
|
|
75
|
+
[".docker", "config.json"], [".local", "share", "keyrings"], [".cargo", "credentials.toml"], [".gem", "credentials"],
|
|
76
|
+
[".terraform.d", "credentials.tfrc.json"], [".m2", "settings.xml"], [".oci", "config"],
|
|
77
|
+
["library", "cookies"], ["library", "safari"], ["library", "mail"], ["library", "messages"],
|
|
78
|
+
["library", "application support", "google", "chrome"], ["library", "application support", "chromium"],
|
|
79
|
+
["library", "application support", "bravesoftware"], ["library", "application support", "microsoft edge"],
|
|
80
|
+
["library", "application support", "arc"], ["library", "application support", "firefox"],
|
|
81
|
+
["library", "application support", "1password"], ["library", "group containers"],
|
|
82
|
+
];
|
|
67
83
|
// Exact file names that hold secrets (compared folded).
|
|
68
|
-
const SENSITIVE_NAMES
|
|
84
|
+
export const SENSITIVE_NAMES = [
|
|
69
85
|
".env", ".envrc", ".netrc", ".npmrc", ".pypirc", ".git-credentials", ".dev.vars", "keys.json", "credentials.json", ".yarnrc.yml", ".pgpass", ".my.cnf",
|
|
70
|
-
|
|
86
|
+
"_netrc", ".boto", ".s3cfg", ".vault-token", ".terraformrc", "login data", "cookies", "cookies.binarycookies", "key4.db", "logins.json",
|
|
87
|
+
".zsh_history", ".bash_history", ".python_history", ".node_repl_history", ".psql_history", ".mysql_history", ".lesshst",
|
|
88
|
+
".yarnrc",
|
|
89
|
+
];
|
|
90
|
+
// File extensions of private keys, certificates with keys, key stores and infrastructure state.
|
|
91
|
+
export const SENSITIVE_EXTENSIONS = [
|
|
92
|
+
"pem", "key", "p12", "p8", "pfx", "jks", "keystore", "ppk", "tfvars", "tfstate", "keychain", "keychain-db", "kdbx",
|
|
93
|
+
];
|
|
94
|
+
// Name patterns, each with the looser form a shell command is checked with. One table for both.
|
|
95
|
+
// `word` rules guess from a word in the name, so they skip source code (src/auth/credentials.ts is
|
|
96
|
+
// code about credentials, not credentials).
|
|
97
|
+
export const SENSITIVE_PATTERNS = [
|
|
98
|
+
{ name: /^\.env\.|\.env$/, shell: /\.env/ }, // .env.local, prod.env, config.env
|
|
99
|
+
{ name: /\.tfstate(\.|$)/, shell: /\.tfstate/ }, // terraform.tfstate, terraform.tfstate.backup
|
|
100
|
+
{ name: /service[-_]?account.*\.json$/, shell: /service[-_]?account/ },
|
|
101
|
+
// credential(s) or secret(s) as a word ("client_secret.json", "aws-secrets.yaml"; not "secretary.ts")
|
|
102
|
+
{ name: /(^|[^a-z])(credential|secret)s?([^a-z]|$)/, shell: /credential|secret/, word: true },
|
|
103
|
+
// shell and REPL histories (.zsh_history, .python_history, history): the name ends in "history"
|
|
104
|
+
{ name: /history$/, shell: /history/ }, // only a name that ends in "history": history.md and chat_history.ts never match
|
|
105
|
+
{ name: /^id_[a-z0-9_]+$/, shell: /\bid_[a-z0-9]/ }, // id_rsa, id_ed25519; id_rsa.pub is not matched
|
|
106
|
+
];
|
|
107
|
+
// Source files: a word rule never applies to these (named secret files still do).
|
|
108
|
+
export const SOURCE_EXTENSIONS = [
|
|
109
|
+
"ts", "tsx", "js", "jsx", "mjs", "cjs", "py", "go", "rs", "rb", "java", "kt", "swift", "c", "cc", "cpp", "h", "cs", "php", "vue", "svelte",
|
|
110
|
+
];
|
|
111
|
+
const SOURCE_RE = new RegExp(`\\.(${SOURCE_EXTENSIONS.join("|")})$`);
|
|
112
|
+
// Source files whose whole stem says secret (secrets.py, credentials.js, client_secret.ts) often
|
|
113
|
+
// hold real keys: the word rules let them through, so read_file asks first and grep and glob skip
|
|
114
|
+
// them. A longer name (secret_manager.go, credentials_form.ts) is ordinary code.
|
|
115
|
+
export const SECRET_STEMS = ["secret", "secrets", "credential", "credentials", "client_secret", "client_secrets"];
|
|
116
|
+
|
|
117
|
+
export function isSecretStemSource(abs ) {
|
|
118
|
+
const name = fold(abs.split(sep).join("/")).split("/").pop() ?? "";
|
|
119
|
+
if (!SOURCE_RE.test(name)) return false;
|
|
120
|
+
return SECRET_STEMS.includes(name.slice(0, name.lastIndexOf(".")));
|
|
121
|
+
}
|
|
122
|
+
// First segments of SENSITIVE_RUNS that are credential folders in their own right, so a shell
|
|
123
|
+
// command that names the folder (ls .docker, cat ~/.docker/*) asks too. .config and .local hold
|
|
124
|
+
// much else and are covered by the home rule.
|
|
125
|
+
const RUN_FOLDERS = [...new Set(SENSITIVE_RUNS.map((r) => r[0] ))].filter((f) => f.startsWith(".") && f !== ".config" && f !== ".local");
|
|
126
|
+
const NAME_SET = new Set(SENSITIVE_NAMES);
|
|
127
|
+
const DIR_SET = new Set(SENSITIVE_DIRS);
|
|
71
128
|
const EXAMPLE_SUFFIX = /\.(example|sample|template|dist)$/;
|
|
129
|
+
const EXT_RE = new RegExp(`\\.(${SENSITIVE_EXTENSIONS.map((e) => e.replace(/[.-]/g, "\\$&")).join("|")})$`);
|
|
72
130
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
export function isSensitivePath(abs , cfg = configDir()) {
|
|
77
|
-
const norm = fold(abs.split(sep).join("/"));
|
|
78
|
-
const parts = norm.split("/").filter(Boolean);
|
|
79
|
-
if (parts.some((d) => SENSITIVE_DIRS.has(d))) return true;
|
|
80
|
-
for (let i = 0; i + 1 < parts.length; i++) {
|
|
81
|
-
if (parts[i] === ".config" && parts[i + 1] === "synqa") return true;
|
|
82
|
-
if (parts[i] === ".kube" && parts[i + 1] === "config") return true;
|
|
83
|
-
if (parts[i] === ".docker" && parts[i + 1] === "config.json") return true;
|
|
131
|
+
function hasRun(parts , run ) {
|
|
132
|
+
for (let i = 0; i + run.length <= parts.length; i++) {
|
|
133
|
+
if (run.every((seg, j) => parts[i + j] === seg)) return true;
|
|
84
134
|
}
|
|
85
|
-
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// One slash form for every comparison. On win32 both separators count and the drive letter and
|
|
139
|
+
// every name are folded, so a Windows AppData folder in any letter case matches. The platform is
|
|
140
|
+
// a parameter so the Windows rules are tested on any machine.
|
|
141
|
+
const slashed = (p , platform = process.platform) =>
|
|
142
|
+
fold(platform === "win32" ? p.replace(/\\/g, "/") : p.split(sep).join("/")).replace(/\/+$/, "");
|
|
143
|
+
|
|
144
|
+
// The home folder, as given and with symlinks resolved, folded.
|
|
145
|
+
function homeForms(home , platform = process.platform) {
|
|
146
|
+
const out = [slashed(home, platform)];
|
|
147
|
+
try {
|
|
148
|
+
out.push(slashed(realpathSync(home), platform));
|
|
149
|
+
} catch {
|
|
150
|
+
// a home that does not exist is compared as given
|
|
151
|
+
}
|
|
152
|
+
return out.filter((h) => h !== "" && h !== "/");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Inside the home folder, everything under a dot entry at its top (~/.zsh_history, ~/.config/**,
|
|
156
|
+
// ~/.cargo/**, any tool's credential store, known or not) and everything under
|
|
157
|
+
// ~/Library is protected. This is the structural rule: it does not depend on naming each store.
|
|
158
|
+
// A project's own dot files (~/proj/.github, ~/proj/.eslintrc) are below a normal folder and stay usable.
|
|
159
|
+
// On Windows the same rule covers AppData (browser profiles, tokens, credential stores) and the
|
|
160
|
+
// per-user registry hive NTUSER.DAT with its log and backup files.
|
|
161
|
+
export function homeHidden(abs , home = homedir(), platform = process.platform) {
|
|
162
|
+
const norm = slashed(abs, platform);
|
|
163
|
+
for (const h of homeForms(home, platform)) {
|
|
164
|
+
if (!norm.startsWith(h + "/")) continue;
|
|
165
|
+
const first = norm.slice(h.length + 1).split("/")[0] ?? "";
|
|
166
|
+
if (first.startsWith(".") || first === "library") return true;
|
|
167
|
+
if (platform === "win32" && (first === "appdata" || first.startsWith("ntuser.dat") || first === "ntuser.ini")) return true;
|
|
168
|
+
}
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// True when `cwd` is the home folder itself.
|
|
173
|
+
export function isHomeDir(cwd , home = homedir(), platform = process.platform) {
|
|
174
|
+
let real = slashed(cwd, platform);
|
|
175
|
+
try {
|
|
176
|
+
real = slashed(realpathSync(cwd), platform);
|
|
177
|
+
} catch {
|
|
178
|
+
// compared as given
|
|
179
|
+
}
|
|
180
|
+
return homeForms(home, platform).includes(real) || homeForms(home, platform).includes(slashed(cwd, platform));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Files and folders that hold secrets: the home folder's dot entries and Library (homeHidden),
|
|
184
|
+
// env files, private keys, cloud, package manager and container credentials, keychains, browser
|
|
185
|
+
// profiles, histories, and this CLI's own config (which holds the API key). Names only, no file is
|
|
186
|
+
// opened. Every segment is folded first (see fold). read_file, write_file and edit_file refuse
|
|
187
|
+
// these outright; grep and glob skip them.
|
|
188
|
+
export function isSensitivePath(abs , cfg = configDir(), home = homedir(), platform = process.platform) {
|
|
189
|
+
if (homeHidden(abs, home, platform)) return true;
|
|
190
|
+
const norm = slashed(abs, platform);
|
|
191
|
+
const parts = norm.split("/").filter(Boolean);
|
|
192
|
+
if (parts.some((d) => DIR_SET.has(d))) return true;
|
|
193
|
+
if (SENSITIVE_RUNS.some((run) => hasRun(parts, run))) return true;
|
|
194
|
+
const c = slashed(cfg, platform);
|
|
86
195
|
if (c && (norm === c || norm.startsWith(c + "/"))) return true;
|
|
87
196
|
const name = parts[parts.length - 1] ?? "";
|
|
88
197
|
// Remotes can hold tokens: .git/config, .git/config.worktree, .git/worktrees/<name>/config, at any depth.
|
|
89
198
|
if (parts.slice(0, -1).includes(".git") && (name === "config" || name.startsWith("config."))) return true;
|
|
90
|
-
if (
|
|
91
|
-
if (
|
|
92
|
-
if (
|
|
93
|
-
|
|
94
|
-
return
|
|
199
|
+
if (NAME_SET.has(name)) return true;
|
|
200
|
+
if (EXAMPLE_SUFFIX.test(name)) return false;
|
|
201
|
+
if (EXT_RE.test(name)) return true;
|
|
202
|
+
const source = SOURCE_RE.test(name);
|
|
203
|
+
return SENSITIVE_PATTERNS.some((x) => x.name.test(name) && !(x.word && source));
|
|
95
204
|
}
|
|
96
205
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
206
|
+
const escRe = (s ) => s.replace(/[.*+?^${}()|[\]\\/-]/g, "\\$&");
|
|
207
|
+
|
|
208
|
+
// Names a shell command can use to reach a secret, including globs that expand to them, built from
|
|
209
|
+
// the same tables as isSensitivePath. Heuristic: it makes an unattended (--yes) command ask when it
|
|
210
|
+
// looks at secrets, it does not make bash safe.
|
|
211
|
+
const SECRET_WORDS = [
|
|
212
|
+
...SENSITIVE_DIRS.map((d) => new RegExp(escRe(d))),
|
|
213
|
+
...SENSITIVE_RUNS.map((r) => new RegExp(r.map(escRe).join("/"))),
|
|
214
|
+
...SENSITIVE_NAMES.map((n) => new RegExp(escRe(n))),
|
|
215
|
+
...RUN_FOLDERS.map((f) => new RegExp(`${escRe(f)}\\b`)),
|
|
216
|
+
new RegExp(`\\.(${SENSITIVE_EXTENSIONS.map(escRe).join("|")})\\b`),
|
|
217
|
+
...SENSITIVE_PATTERNS.map((x) => x.shell),
|
|
218
|
+
/\.git\/config/,
|
|
219
|
+
// Dumping the environment (where tokens live): printenv, env or export -p or set with no arguments.
|
|
220
|
+
/\bprintenv\b/, /(^|[;&|(]\s*)env\s*($|[;&|)>])/, /\bexport\s+-p\b/, /(^|[;&|(]\s*)(set|declare\s+-x)\s*($|[;&|)>])/,
|
|
221
|
+
/\.config\/synqa/, /synqa\/config/,
|
|
222
|
+
/(^|[\s/])\.[\w.-]*[*?[]/, // a dot glob such as .e* or .*
|
|
223
|
+
/~\/?\./, /\$\{?home\}?\/?\./, /~\/library/, /\$\{?home\}?\/library/, // the home folder's dot entries and Library
|
|
224
|
+
/\bsecurity\s+(find|dump|export)-/, /\.binarycookies/,
|
|
103
225
|
];
|
|
104
226
|
|
|
105
227
|
export function commandTouchesSecrets(command , cfg = configDir()) {
|
|
228
|
+
// Escapes that can spell any name ($'\x2eenv', \056, printf '\x2e'): always ask.
|
|
229
|
+
if (/\$'|\\x[0-9a-f]{2}|\\[0-7]{3}|\\u[0-9a-f]{4}/i.test(command)) return true;
|
|
106
230
|
// Quotes and backslashes are dropped first so .e""nv and .\env read as .env.
|
|
107
231
|
const c = fold(command).replace(/["'\\`]/g, "");
|
|
108
232
|
if (SECRET_WORDS.some((re) => re.test(c))) return true;
|
|
@@ -110,22 +234,17 @@ export function commandTouchesSecrets(command , cfg = configDir()
|
|
|
110
234
|
return dir !== "" && c.includes(dir);
|
|
111
235
|
}
|
|
112
236
|
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
|
|
237
|
+
// The one start folder this CLI refuses: the filesystem root, where the working directory would be
|
|
238
|
+
// the whole disk. Anywhere else, the home folder included, it starts like any other coding CLI;
|
|
239
|
+
// secrets there are protected by isSensitivePath, not by refusing to start. Returns the reason,
|
|
240
|
+
// or undefined when the folder is fine.
|
|
241
|
+
export function unsafeStartDir(cwd ) {
|
|
116
242
|
let real ;
|
|
117
|
-
let realHome ;
|
|
118
243
|
try {
|
|
119
244
|
real = realpathSync(cwd);
|
|
120
245
|
} catch {
|
|
121
246
|
return undefined;
|
|
122
247
|
}
|
|
123
|
-
try {
|
|
124
|
-
realHome = realpathSync(home);
|
|
125
|
-
} catch {
|
|
126
|
-
realHome = home;
|
|
127
|
-
}
|
|
128
248
|
if (real === parse(real).root) return "the filesystem root";
|
|
129
|
-
if (real === realHome) return "your home folder";
|
|
130
249
|
return undefined;
|
|
131
250
|
}
|
package/dist/prompt.js
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
// The interactive prompt: the slash command table, the inline command menu, /help, and the frame
|
|
2
|
+
// drawn around the input line (a rule above, a rule, the status line and key hints below).
|
|
3
|
+
//
|
|
4
|
+
// The frame and menu use only node:readline keypress events, no extra package. Keys from the
|
|
5
|
+
// terminal are read here and forwarded to readline through a PassThrough, so the menu can take the
|
|
6
|
+
// arrow keys, Tab, Enter and Esc for itself while it is open. The only control sequences written
|
|
7
|
+
// are the fixed strings in this file (cursor up and column, erase line); every piece of text is our
|
|
8
|
+
// own table or the user's own typing, already shown by readline.
|
|
9
|
+
import { PassThrough } from "node:stream";
|
|
10
|
+
import { emitKeypressEvents } from "node:readline";
|
|
11
|
+
|
|
12
|
+
import { bold, dim, stone, terracottaBold } from "./theme.js";
|
|
13
|
+
import { fit, oneLine, pad, PLACEHOLDER, rule, styleOf, textWidth } from "./ui.js";
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
export const SLASH_COMMANDS = [
|
|
20
|
+
{ name: "/model", args: "blink|sonata|aeon", desc: "Choose the model (arrow keys)", group: "Session" },
|
|
21
|
+
{ name: "/effort", args: "default|low|medium|high", desc: "Set reasoning effort (arrow keys)", group: "Session" },
|
|
22
|
+
{ name: "/provider", desc: "Switch provider: Synqa Cloud, OpenCode, ...", group: "Session" },
|
|
23
|
+
{ name: "/login", desc: "Sign in to a provider", group: "Session" },
|
|
24
|
+
{ name: "/logout", desc: "Sign out of a provider or fully", group: "Session" },
|
|
25
|
+
{ name: "/status", desc: "Tier, routing, tokens, credit and weekly pool", group: "Session" },
|
|
26
|
+
{ name: "/compact", desc: "Summarize the conversation to save context", group: "Session" },
|
|
27
|
+
{ name: "/clear", desc: "Start a fresh conversation", group: "Session" },
|
|
28
|
+
{ name: "/init", desc: "Write a starter AGENTS.md for this project", group: "Project" },
|
|
29
|
+
{ name: "/undo", desc: "Revert the last file the agent wrote", group: "Project" },
|
|
30
|
+
{ name: "/hooks", desc: "List hooks and switch them on or off", group: "General" },
|
|
31
|
+
{ name: "/update", desc: "Install the latest @synqa/cli", group: "General" },
|
|
32
|
+
{ name: "/help", desc: "Show commands and keys", group: "General" },
|
|
33
|
+
{ name: "/exit", desc: "Leave Synqa", group: "General" },
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
// Commands whose name starts with what was typed, then those that contain it.
|
|
37
|
+
export function matchCommands(typed ) {
|
|
38
|
+
const t = typed.toLowerCase();
|
|
39
|
+
if (!t.startsWith("/")) return [];
|
|
40
|
+
const starts = SLASH_COMMANDS.filter((c) => c.name.startsWith(t));
|
|
41
|
+
const contains = SLASH_COMMANDS.filter((c) => !c.name.startsWith(t) && c.name.includes(t.slice(1)));
|
|
42
|
+
return [...starts, ...contains];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function distance(a , b ) {
|
|
46
|
+
const d = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array (b.length).fill(0)]);
|
|
47
|
+
for (let j = 1; j <= b.length; j++) (d[0] )[j] = j;
|
|
48
|
+
for (let i = 1; i <= a.length; i++) {
|
|
49
|
+
for (let j = 1; j <= b.length; j++) {
|
|
50
|
+
const row = d[i] ;
|
|
51
|
+
const prev = d[i - 1] ;
|
|
52
|
+
row[j] = Math.min((prev[j] ) + 1, (row[j - 1] ) + 1, (prev[j - 1] ) + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return (d[a.length] )[b.length] ;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// One line for an unknown command, with the nearest name when one is close.
|
|
59
|
+
export function unknownCommandHint(word ) {
|
|
60
|
+
const w = oneLine(word).toLowerCase().slice(0, 40);
|
|
61
|
+
const near = matchCommands(w)[0] ?? SLASH_COMMANDS
|
|
62
|
+
.map((c) => ({ c, d: distance(w, c.name) }))
|
|
63
|
+
.filter((x) => x.d <= 2)
|
|
64
|
+
.sort((a, b) => a.d - b.d)[0]?.c;
|
|
65
|
+
return near ? `Unknown command ${w}. Did you mean ${near.name}? Type / to see all commands.` : `Unknown command ${w}. Type / to see all commands.`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const usage = (c ) => (c.args ? `${c.name} ${c.args}` : c.name);
|
|
69
|
+
|
|
70
|
+
// /help: grouped, aligned, names bold and descriptions dim.
|
|
71
|
+
export function renderHelp(ui ) {
|
|
72
|
+
const s = styleOf(ui);
|
|
73
|
+
const room = ui.columns - 1;
|
|
74
|
+
const w = Math.max(...SLASH_COMMANDS.map((c) => usage(c).length)) + 3;
|
|
75
|
+
const out = [];
|
|
76
|
+
for (const group of ["Session", "Project", "General"] ) {
|
|
77
|
+
out.push(stone(s, group));
|
|
78
|
+
for (const c of SLASH_COMMANDS.filter((x) => x.group === group)) {
|
|
79
|
+
const u = usage(c);
|
|
80
|
+
if (4 + w >= room) { out.push(" " + bold(s, fit(u, room - 2, ui))); continue; }
|
|
81
|
+
out.push(" " + bold(s, c.name) + (c.args ? dim(s, " " + c.args) : "") + " ".repeat(w - u.length) + dim(s, fit(c.desc, room - 2 - w, ui)));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
out.push(dim(s, fit("Type / for the command menu: arrows move, Tab or Enter completes, Esc closes.", room, ui)));
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export const SLASH_HELP = renderHelp({ color: false, unicode: true, tty: false, columns: 100 }).join("\n");
|
|
89
|
+
|
|
90
|
+
const MENU_MAX = 8;
|
|
91
|
+
|
|
92
|
+
// The menu under the input: the selected row has a terracotta pointer and a bold name.
|
|
93
|
+
export function renderMenu(items , selected , ui ) {
|
|
94
|
+
const s = styleOf(ui);
|
|
95
|
+
const room = ui.columns - 1;
|
|
96
|
+
const pointer = ui.unicode ? "❯" : ">";
|
|
97
|
+
const nameW = Math.max(...SLASH_COMMANDS.map((c) => c.name.length)) + 2;
|
|
98
|
+
const start = Math.max(0, Math.min(selected - MENU_MAX + 1, items.length - MENU_MAX));
|
|
99
|
+
return items.slice(start, start + MENU_MAX).map((c, i) => {
|
|
100
|
+
const on = start + i === selected;
|
|
101
|
+
const lead = on ? terracottaBold(s, pointer) + " " : " ";
|
|
102
|
+
const name = pad(c.name, nameW);
|
|
103
|
+
const descRoom = room - 2 - nameW;
|
|
104
|
+
const desc = descRoom > 4 ? fit(c.desc, descRoom, ui) : "";
|
|
105
|
+
if (descRoom <= 4) return lead + (on ? terracottaBold(s, fit(c.name, room - 2, ui)) : fit(c.name, room - 2, ui));
|
|
106
|
+
return lead + (on ? terracottaBold(s, name) + stone(s, desc) : name + dim(s, desc));
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ---------------------------------------------------------------- the frame
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
const ESC = "\x1b";
|
|
117
|
+
|
|
118
|
+
// Draws below the input line and routes keys. One per session, TTY only.
|
|
119
|
+
export class PromptFrame {
|
|
120
|
+
input ;
|
|
121
|
+
rl ;
|
|
122
|
+
shown = 0; // rows drawn below the input line
|
|
123
|
+
active = false; // the main prompt is being answered (not a y/N question, not a turn)
|
|
124
|
+
selected = 0;
|
|
125
|
+
dismissed = false;
|
|
126
|
+
placeholder = false;
|
|
127
|
+
lastLine = "";
|
|
128
|
+
started = false;
|
|
129
|
+
pasting = false; // between the terminal's paste-start and paste-end marks
|
|
130
|
+
ctrlC = false; // Ctrl+C was pressed (typed, not pasted)
|
|
131
|
+
capture ; // a selector or secret prompt owns the keys
|
|
132
|
+
lastCols = 0;
|
|
133
|
+
promptStr = "";
|
|
134
|
+
// Shift+Tab at the prompt (cycles the permission mode); the footer is redrawn after it.
|
|
135
|
+
onShiftTab ;
|
|
136
|
+
|
|
137
|
+
// True once the person pressed Ctrl+C. An end of input is not an interrupt.
|
|
138
|
+
get interrupted() {
|
|
139
|
+
return this.ctrlC;
|
|
140
|
+
}
|
|
141
|
+
onKey = (s , key ) => this.key(s, key);
|
|
142
|
+
|
|
143
|
+
stdin ;
|
|
144
|
+
out ;
|
|
145
|
+
ui ;
|
|
146
|
+
below ; // the status lines
|
|
147
|
+
promptWidth ;
|
|
148
|
+
|
|
149
|
+
paste ; // bracketed paste is switched on (stdout is the terminal)
|
|
150
|
+
pasteOk = false; // a prompt that takes typed text is open: pasted text is typed in
|
|
151
|
+
|
|
152
|
+
// `opts.paste` false when stdout is not the terminal (a piped one-shot): the escape that switches
|
|
153
|
+
// bracketed paste on would land in the output instead.
|
|
154
|
+
constructor(stdin , out , ui , below , promptWidth , opts = {}) {
|
|
155
|
+
this.paste = opts.paste ?? true;
|
|
156
|
+
this.stdin = stdin;
|
|
157
|
+
this.out = out;
|
|
158
|
+
this.ui = ui;
|
|
159
|
+
this.below = below;
|
|
160
|
+
this.promptWidth = promptWidth;
|
|
161
|
+
const pass = new PassThrough() ;
|
|
162
|
+
pass.isTTY = true;
|
|
163
|
+
pass.setRawMode = () => {};
|
|
164
|
+
this.input = pass;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
start(rl ) {
|
|
168
|
+
this.rl = rl;
|
|
169
|
+
emitKeypressEvents(this.stdin);
|
|
170
|
+
this.stdin.setRawMode?.(true);
|
|
171
|
+
this.stdin.on("keypress", this.onKey);
|
|
172
|
+
this.stdin.resume();
|
|
173
|
+
// Bracketed paste: the terminal marks pasted text, so a pasted newline is not an Enter.
|
|
174
|
+
if (this.paste) this.out.write(`${ESC}[?2004h`);
|
|
175
|
+
this.started = true;
|
|
176
|
+
rl.on("close", () => this.stop());
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Gives the terminal back: raw mode and bracketed paste off. Safe to call any number of times.
|
|
180
|
+
stop() {
|
|
181
|
+
if (!this.started) return;
|
|
182
|
+
this.started = false;
|
|
183
|
+
this.stdin.off("keypress", this.onKey);
|
|
184
|
+
if (this.paste) this.out.write(`${ESC}[?2004l`);
|
|
185
|
+
this.stdin.setRawMode?.(false);
|
|
186
|
+
this.stdin.pause();
|
|
187
|
+
this.input.end();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// KeySource: gives every key to fn and none to readline until the returned function is called.
|
|
191
|
+
// Pasted text reaches fn only when asked for (a secret), with its line breaks dropped.
|
|
192
|
+
subscribe(fn , opts = {}) {
|
|
193
|
+
const mine = { fn, paste: opts.paste === true };
|
|
194
|
+
this.capture = mine;
|
|
195
|
+
return () => { if (this.capture === mine) this.capture = undefined; };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// The window changed: when the columns differ, redraw the whole frame (see redrawFrame).
|
|
199
|
+
onResize() {
|
|
200
|
+
const cols = this.cols();
|
|
201
|
+
const prev = this.lastCols || cols;
|
|
202
|
+
this.lastCols = cols;
|
|
203
|
+
if (this.active && !this.capture && cols !== prev) this.redrawFrame(prev);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Rebuilds the framed prompt at the current width: the old top rule is erased (it may have
|
|
207
|
+
// reflowed onto several rows if the window got narrower), then the top rule, the prompt with the
|
|
208
|
+
// typed text, the bottom rule and the footer are drawn again, so no half-width border is left.
|
|
209
|
+
redrawFrame(prevCols ) {
|
|
210
|
+
if (!this.active) return;
|
|
211
|
+
const cols = this.cols();
|
|
212
|
+
const ruleRows = Math.max(1, Math.ceil(Math.max(1, prevCols - 1) / Math.max(1, cols)));
|
|
213
|
+
this.out.write(`\r${ESC}[${ruleRows}A${ESC}[J` + rule(this.ui) + "\n" + this.promptStr + this.line());
|
|
214
|
+
this.shown = 0;
|
|
215
|
+
this.placeholder = false;
|
|
216
|
+
this.draw();
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
line() {
|
|
220
|
+
return (this.rl )?.line ?? "";
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
menuItems() {
|
|
224
|
+
const l = this.line();
|
|
225
|
+
if (!this.active || this.dismissed || !l.startsWith("/") || /\s/.test(l)) return [];
|
|
226
|
+
return matchCommands(l);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// The rule above the prompt; then the prompt itself is asked by the caller.
|
|
230
|
+
async ask(promptText ) {
|
|
231
|
+
const rl = this.rl ;
|
|
232
|
+
this.promptStr = promptText;
|
|
233
|
+
this.lastCols = this.cols();
|
|
234
|
+
this.out.write(rule(this.ui) + "\n");
|
|
235
|
+
this.active = true;
|
|
236
|
+
this.pasteOk = true;
|
|
237
|
+
this.selected = 0;
|
|
238
|
+
this.dismissed = false;
|
|
239
|
+
this.lastLine = "";
|
|
240
|
+
const answer = rl.question(promptText);
|
|
241
|
+
this.draw();
|
|
242
|
+
try {
|
|
243
|
+
return await answer;
|
|
244
|
+
} finally {
|
|
245
|
+
this.active = false;
|
|
246
|
+
this.pasteOk = false;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// A plain prompt with no frame or menu (an ASCII terminal): pasted text is typed in, nothing drawn.
|
|
251
|
+
async askPlain(promptText ) {
|
|
252
|
+
this.pasteOk = true;
|
|
253
|
+
try {
|
|
254
|
+
return await (this.rl ).question(promptText);
|
|
255
|
+
} finally {
|
|
256
|
+
this.pasteOk = false;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Erases what is drawn below the input line (before Enter, so the transcript stays clean).
|
|
261
|
+
clear() {
|
|
262
|
+
if (this.shown === 0) return;
|
|
263
|
+
let s = "";
|
|
264
|
+
for (let i = 0; i < this.shown; i++) s += `\r\n${ESC}[2K`;
|
|
265
|
+
s += `${ESC}[${this.shown}A${ESC}[${this.cursorCol()}G`;
|
|
266
|
+
this.out.write(s);
|
|
267
|
+
this.shown = 0;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
cursorCol() {
|
|
271
|
+
const pos = (this.rl )?.getCursorPos?.();
|
|
272
|
+
return (pos?.cols ?? this.promptWidth + textWidth(this.line())) + 1;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
cols() {
|
|
276
|
+
return this.ui.columns;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// What goes under the input: the rule, then the menu or the status lines.
|
|
280
|
+
belowLines() {
|
|
281
|
+
const items = this.menuItems();
|
|
282
|
+
if (this.selected >= items.length) this.selected = 0;
|
|
283
|
+
const body = items.length ? renderMenu(items, this.selected, this.ui) : this.below();
|
|
284
|
+
return [rule(this.ui), ...body];
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
draw() {
|
|
288
|
+
if (!this.active) return;
|
|
289
|
+
const l = this.line();
|
|
290
|
+
// A line that wraps would put the frame in the wrong place: draw nothing until it is short.
|
|
291
|
+
if (this.promptWidth + textWidth(l) >= this.cols() - 1) { this.clear(); return; }
|
|
292
|
+
const lines = this.belowLines();
|
|
293
|
+
const n = Math.max(lines.length, this.shown);
|
|
294
|
+
let s = "";
|
|
295
|
+
for (let i = 0; i < n; i++) s += `\r\n${ESC}[2K` + (lines[i] ?? "");
|
|
296
|
+
const col = this.cursorCol();
|
|
297
|
+
s += `${ESC}[${n}A${ESC}[${col}G`;
|
|
298
|
+
if (l === "") {
|
|
299
|
+
s += dim(styleOf(this.ui), fit(PLACEHOLDER, this.cols() - 1 - col, this.ui)) + `${ESC}[${col}G`;
|
|
300
|
+
this.placeholder = true;
|
|
301
|
+
} else if (this.placeholder) {
|
|
302
|
+
s = `${ESC}[K` + s;
|
|
303
|
+
this.placeholder = false;
|
|
304
|
+
}
|
|
305
|
+
this.out.write(s);
|
|
306
|
+
this.shown = lines.length;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
forward(seq ) {
|
|
310
|
+
this.input.write(seq);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
replaceLine(text ) {
|
|
314
|
+
// ctrl+e (end of line), ctrl+u (delete to the start), then the new text.
|
|
315
|
+
this.forward("\x05\x15" + text);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
key(s , key ) {
|
|
319
|
+
const seq = key?.sequence ?? s ?? "";
|
|
320
|
+
if (key?.name === "paste-start") { this.pasting = true; return; }
|
|
321
|
+
if (key?.name === "paste-end") { this.pasting = false; if (!this.capture) this.after(); return; }
|
|
322
|
+
if (this.capture) {
|
|
323
|
+
if (this.pasting) {
|
|
324
|
+
const k = key?.name;
|
|
325
|
+
if (this.capture.paste && k !== "return" && k !== "enter" && seq !== "\r" && seq !== "\n") this.capture.fn(s, key);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
this.capture.fn(s, key);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
if (!this.pasting && seq === "\x03") this.ctrlC = true;
|
|
332
|
+
if (this.pasting) {
|
|
333
|
+
// Pasted text is never a key press. While a y/N question (or a turn) holds the input it is
|
|
334
|
+
// dropped, so a pasted "y" and newline cannot approve anything. At the prompt it is typed
|
|
335
|
+
// as text, with each newline as a space so a paste never sends the line.
|
|
336
|
+
if (!this.pasteOk) return;
|
|
337
|
+
const name = key?.name;
|
|
338
|
+
this.forward(name === "return" || name === "enter" || seq === "\r" || seq === "\n" ? " " : seq);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
if (!this.active) { this.forward(seq); return; }
|
|
342
|
+
const items = this.menuItems();
|
|
343
|
+
const name = key?.name;
|
|
344
|
+
if (name === "tab" && key?.shift) { this.onShiftTab?.(); this.draw(); return; }
|
|
345
|
+
// A lone Esc closes the menu (node reports it with meta set on some versions).
|
|
346
|
+
if (items.length && name === "escape") { this.dismissed = true; this.draw(); return; }
|
|
347
|
+
if (items.length && !key?.ctrl && !key?.meta) {
|
|
348
|
+
if (name === "up" || name === "down") {
|
|
349
|
+
this.selected = (this.selected + (name === "up" ? -1 : 1) + items.length) % items.length;
|
|
350
|
+
this.draw();
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
const pick = items[this.selected] ;
|
|
354
|
+
if (name === "tab") { this.replaceLine(pick.args ? pick.name + " " : pick.name); this.after(); return; }
|
|
355
|
+
if (name === "return" || name === "enter") {
|
|
356
|
+
this.clear();
|
|
357
|
+
this.replaceLine(pick.name);
|
|
358
|
+
this.forward("\r");
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
if (name === "return" || name === "enter") { this.clear(); this.forward(seq); return; }
|
|
363
|
+
if (name === "escape" && this.line() === "") return; // nothing to dismiss; a lone Esc is ignored
|
|
364
|
+
this.forward(seq);
|
|
365
|
+
this.after();
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Readline handles the forwarded keys on the next tick; redraw after it has.
|
|
369
|
+
after() {
|
|
370
|
+
setImmediate(() => {
|
|
371
|
+
const l = this.line();
|
|
372
|
+
if (l !== this.lastLine) { this.dismissed = false; this.selected = 0; }
|
|
373
|
+
this.lastLine = l;
|
|
374
|
+
this.draw();
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
redraw() {
|
|
379
|
+
this.draw();
|
|
380
|
+
}
|
|
381
|
+
}
|