@pentoshi/clai 2.0.28 → 2.0.29

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.
Files changed (105) hide show
  1. package/dist/agent/confirm-port.d.ts +30 -0
  2. package/dist/agent/confirm-port.js +89 -0
  3. package/dist/agent/confirm-port.js.map +1 -0
  4. package/dist/agent/plan-tool.d.ts +26 -0
  5. package/dist/agent/plan-tool.js +188 -0
  6. package/dist/agent/plan-tool.js.map +1 -0
  7. package/dist/agent/runner.d.ts +6 -174
  8. package/dist/agent/runner.js +25 -1606
  9. package/dist/agent/runner.js.map +1 -1
  10. package/dist/agent/session-policy.d.ts +19 -0
  11. package/dist/agent/session-policy.js +40 -0
  12. package/dist/agent/session-policy.js.map +1 -0
  13. package/dist/agent/stop-summary.d.ts +8 -0
  14. package/dist/agent/stop-summary.js +53 -0
  15. package/dist/agent/stop-summary.js.map +1 -0
  16. package/dist/agent/tool-call-parser.d.ts +155 -0
  17. package/dist/agent/tool-call-parser.js +1107 -0
  18. package/dist/agent/tool-call-parser.js.map +1 -0
  19. package/dist/agent/tool-output-formatting.d.ts +7 -0
  20. package/dist/agent/tool-output-formatting.js +127 -0
  21. package/dist/agent/tool-output-formatting.js.map +1 -0
  22. package/dist/commands/update.js +1 -1
  23. package/dist/prompts/index.d.ts +2 -2
  24. package/dist/prompts/index.js +103 -59
  25. package/dist/prompts/index.js.map +1 -1
  26. package/dist/repl/prompt-line.d.ts +19 -0
  27. package/dist/repl/prompt-line.js +555 -0
  28. package/dist/repl/prompt-line.js.map +1 -0
  29. package/dist/repl/slash-commands.d.ts +24 -0
  30. package/dist/repl/slash-commands.js +317 -0
  31. package/dist/repl/slash-commands.js.map +1 -0
  32. package/dist/repl.d.ts +2 -25
  33. package/dist/repl.js +41 -898
  34. package/dist/repl.js.map +1 -1
  35. package/dist/safety/classifier.js +1 -1
  36. package/dist/safety/classifier.js.map +1 -1
  37. package/dist/store/keys.js +0 -4
  38. package/dist/store/keys.js.map +1 -1
  39. package/dist/store/plan.js +1 -1
  40. package/dist/store/plan.js.map +1 -1
  41. package/dist/tools/nmap-runner.d.ts +19 -0
  42. package/dist/tools/nmap-runner.js +177 -0
  43. package/dist/tools/nmap-runner.js.map +1 -0
  44. package/dist/tools/package-binary.d.ts +1 -0
  45. package/dist/tools/package-binary.js +30 -0
  46. package/dist/tools/package-binary.js.map +1 -0
  47. package/dist/tools/pdf.js +2 -2
  48. package/dist/tools/pdf.js.map +1 -1
  49. package/dist/tools/registry.d.ts +2 -11
  50. package/dist/tools/registry.js +3 -203
  51. package/dist/tools/registry.js.map +1 -1
  52. package/dist/tools/tool-types.d.ts +12 -0
  53. package/dist/tools/tool-types.js +2 -0
  54. package/dist/tools/tool-types.js.map +1 -0
  55. package/dist/tools/web/audit.d.ts +7 -29
  56. package/dist/tools/web/audit.js +7 -29
  57. package/dist/tools/web/audit.js.map +1 -1
  58. package/dist/tools/web/budget.d.ts +5 -28
  59. package/dist/tools/web/budget.js +5 -28
  60. package/dist/tools/web/budget.js.map +1 -1
  61. package/dist/tools/web/capture.d.ts +8 -35
  62. package/dist/tools/web/capture.js +8 -35
  63. package/dist/tools/web/capture.js.map +1 -1
  64. package/dist/tools/web/fetch-core.d.ts +6 -26
  65. package/dist/tools/web/fetch-core.js +6 -26
  66. package/dist/tools/web/fetch-core.js.map +1 -1
  67. package/dist/tools/web/readable.d.ts +7 -13
  68. package/dist/tools/web/readable.js +7 -13
  69. package/dist/tools/web/readable.js.map +1 -1
  70. package/dist/tools/web/search.d.ts +7 -20
  71. package/dist/tools/web/search.js +7 -20
  72. package/dist/tools/web/search.js.map +1 -1
  73. package/dist/tools/web/ssrf-guard.d.ts +8 -27
  74. package/dist/tools/web/ssrf-guard.js +8 -27
  75. package/dist/tools/web/ssrf-guard.js.map +1 -1
  76. package/dist/tui/App.js +19 -88
  77. package/dist/tui/App.js.map +1 -1
  78. package/dist/tui/key-intent.d.ts +36 -0
  79. package/dist/tui/key-intent.js +54 -0
  80. package/dist/tui/key-intent.js.map +1 -0
  81. package/dist/tui/render-lines.js +125 -37
  82. package/dist/tui/render-lines.js.map +1 -1
  83. package/dist/tui/state.js +2 -2
  84. package/dist/tui/state.js.map +1 -1
  85. package/dist/tui/text-format.d.ts +11 -0
  86. package/dist/tui/text-format.js +85 -0
  87. package/dist/tui/text-format.js.map +1 -0
  88. package/dist/ui/ansi-box.d.ts +7 -0
  89. package/dist/ui/ansi-box.js +18 -0
  90. package/dist/ui/ansi-box.js.map +1 -0
  91. package/dist/ui/banner.d.ts +0 -9
  92. package/dist/ui/banner.js +1 -43
  93. package/dist/ui/banner.js.map +1 -1
  94. package/dist/ui/intro-card.d.ts +10 -0
  95. package/dist/ui/intro-card.js +39 -0
  96. package/dist/ui/intro-card.js.map +1 -0
  97. package/dist/ui/markdown.js +1 -1
  98. package/dist/ui/markdown.js.map +1 -1
  99. package/dist/ui/output-pane.js.map +1 -1
  100. package/dist/ui/spinner.js +6 -17
  101. package/dist/ui/spinner.js.map +1 -1
  102. package/dist/ui/wordmark.d.ts +7 -0
  103. package/dist/ui/wordmark.js +42 -0
  104. package/dist/ui/wordmark.js.map +1 -0
  105. package/package.json +1 -1
package/dist/repl.js CHANGED
@@ -1,4 +1,4 @@
1
- import { clearLine, cursorTo, emitKeypressEvents, moveCursor, } from "node:readline";
1
+ import { clearLine, cursorTo, emitKeypressEvents, moveCursor } from "node:readline";
2
2
  import { stdin as input, stdout as output } from "node:process";
3
3
  import chalk from "chalk";
4
4
  import { confirm } from "@inquirer/prompts";
@@ -11,9 +11,9 @@ import { getConfig, getProviderModel, setDefaultMode, setProviderModel, setThink
11
11
  import { listSessions, saveSession, clearAllHistory, getSession, } from "./store/history.js";
12
12
  import { assertProvider, defaultModels, getProviderInfoText } from "./llm/provider.js";
13
13
  import { getProvider, providerAuth } from "./llm/router.js";
14
- import { nvidiaFallbackModels } from "./llm/nvidia.js";
15
14
  import { runUpdate, checkForUpdateSilent, getCurrentVersion, } from "./commands/update.js";
16
- import { renderBanner, renderSessionInfo, renderSuggestions, renderModeSwitch, renderProviderSwitch, PROMPT, } from "./ui/banner.js";
15
+ import { renderModeSwitch, renderProviderSwitch, PROMPT, } from "./ui/banner.js";
16
+ import { renderIntroCard } from "./ui/intro-card.js";
17
17
  import { clearThinking, createThinkingStreamParser, getLastThinking, rememberThinkingFromText, renderAllThinking, renderThinkingSummary, renderThinkingToggleMessage, } from "./ui/thinking.js";
18
18
  import { createMarkdownStreamWriter, renderMarkdown } from "./ui/markdown.js";
19
19
  import { startThinkingSpinner } from "./ui/spinner.js";
@@ -24,280 +24,34 @@ import { renderPlanDocument, renderPlanChecklist } from "./ui/plan-pane.js";
24
24
  import { safeCwd, cwdIsBroken, recoverCwd } from "./os/cwd.js";
25
25
  import { compactMessages, estimateMessagesTokens, } from "./agent/context-manager.js";
26
26
  import { isCtrlC, isCtrlO, isCtrlP, isCtrlT, isEscape } from "./ui/keys.js";
27
- import { getMentionQuery, findFileSuggestions, expandMentions, loadImageAttachments, imageAttachmentPaths, } from "./ui/mentions.js";
27
+ import { expandMentions, loadImageAttachments, imageAttachmentPaths, } from "./ui/mentions.js";
28
28
  import { imageOcr } from "./tools/image.js";
29
- /** Shared command catalogue used by both the classic REPL and the Ink TUI. */
30
- export const slashCommands = [
31
- { command: "/ask", description: "switch to ask mode" },
32
- { command: "/agent", description: "switch to agent mode" },
33
- {
34
- command: "/model",
35
- usage: "[name|#]",
36
- description: "open searchable picker (type/↑/↓ + Enter), or pass a name/number",
37
- },
38
- {
39
- command: "/provider",
40
- usage: "[name]",
41
- description: "switch provider or open picker",
42
- },
43
- {
44
- command: "/use",
45
- usage: "<provider>",
46
- description: "alias for /provider <name>",
47
- },
48
- { command: "/set", usage: "[provider] [key]", description: "store API key or open picker" },
49
- { command: "/unset", usage: "[provider]", description: "remove key or open picker" },
50
- { command: "/keys", description: "list configured providers" },
51
- {
52
- command: "/info",
53
- usage: "[provider]",
54
- description: "show info for the active or specified provider",
55
- },
56
- {
57
- command: "/search",
58
- usage: "[provider]",
59
- description: "switch web.search provider or open picker",
60
- },
61
- {
62
- command: "/search-provider",
63
- usage: "[provider]",
64
- description: "alias for /search",
65
- },
66
- {
67
- command: "/mouse",
68
- usage: "[on|off]",
69
- description: "toggle touchpad transcript scrolling vs native selection",
70
- },
71
- {
72
- command: "/variants",
73
- usage: "[on|off|none|minimal|low|medium|high|xhigh]",
74
- description: "toggle thinking/effort (interactive picker if no arg)",
75
- },
76
- {
77
- command: "/reasoning",
78
- usage: "[on|off|none|minimal|low|medium|high|xhigh]",
79
- description: "alias for /variants",
80
- },
81
- { command: "/clear", description: "clear context" },
82
- {
83
- command: "/new",
84
- description: "start a fresh session (clear context, no history carryover)",
85
- },
86
- {
87
- command: "/history",
88
- description: "browse & resume past sessions (interactive picker)",
89
- },
90
- { command: "/save", usage: "<name>", description: "save session" },
91
- { command: "/reset", description: "clear all saved history" },
92
- { command: "/cwd", usage: "<path>", description: "change working directory" },
93
- {
94
- command: "/allow",
95
- usage: "<tool>|list",
96
- description: "allow a tool for this session (not persisted)",
97
- },
98
- {
99
- command: "/disallow",
100
- usage: "<tool>",
101
- description: "revoke a session allow",
102
- },
103
- { command: "/think", description: "show thinking from last response" },
104
- { command: "/thinking", description: "alias for /think" },
105
- {
106
- command: "/output",
107
- usage: "[last|<id>|list]",
108
- description: "open full tool output (Ctrl+O); q closes the pager",
109
- },
110
- {
111
- command: "/freeonly",
112
- usage: "[on|off]",
113
- description: "skip paid providers when fallback is enabled",
114
- },
115
- {
116
- command: "/fallback",
117
- usage: "[on|off]",
118
- description: "try other configured providers after a failure (off by default)",
119
- },
120
- { command: "/compact", description: "compact session history now" },
121
- { command: "/context", description: "show estimated context size" },
122
- {
123
- command: "/plan",
124
- description: "view the current session plan (also Ctrl+P)",
125
- },
126
- {
127
- command: "/implement",
128
- description: "approve the current plan and have clai execute it",
129
- },
130
- {
131
- command: "/discard",
132
- description: "discard the current plan so later messages ignore it",
133
- },
134
- {
135
- command: "/scope",
136
- usage: "[show|clear|new|add <targets>]",
137
- description: "manage pentest engagement scope",
138
- },
139
- {
140
- command: "/privacy",
141
- usage: "[status|clear-history|clear-logs|clear-artifacts|clear-all|on|off]",
142
- description: "control retention and private mode (in-memory only)",
143
- },
144
- { command: "/update", description: "check for updates" },
145
- {
146
- command: "/clean",
147
- description: "clear screen and reset chat (fresh start)",
148
- },
149
- { command: "/exit", description: "quit" },
150
- { command: "/quit", description: "alias for /exit" },
151
- { command: "/help", description: "list commands" },
152
- ];
153
- // ── Well-known models per provider (refreshed May 2026) ───────────────────
154
- /** Curated model choices used by both frontends. */
155
- export const knownModels = {
156
- groq: [
157
- "openai/gpt-oss-20b",
158
- "openai/gpt-oss-120b",
159
- "openai/gpt-oss-safeguard-20b",
160
- "qwen/qwen3-32b",
161
- "llama-3.3-70b-versatile",
162
- "llama-3.1-8b-instant",
163
- "meta-llama/llama-4-scout-17b-16e-instruct",
164
- "groq/compound-mini",
165
- "groq/compound",
166
- ],
167
- gemini: [
168
- "gemini-3.5-flash",
169
- "gemini-3.1-pro-preview",
170
- "gemini-3-flash-preview",
171
- "gemini-3.1-flash-lite",
172
- "gemini-3-pro-preview",
173
- "gemini-2.5-pro",
174
- "gemini-2.5-flash",
175
- "gemini-2.5-flash-lite",
176
- "gemini-2.0-flash",
177
- "gemini-2.0-flash-lite",
178
- ],
179
- openrouter: [
180
- "meta-llama/llama-3.3-70b-instruct:free",
181
- "deepseek/deepseek-v4-flash:free",
182
- "openai/gpt-oss-20b:free",
183
- "qwen/qwen3-coder:free",
184
- "qwen/qwen3-next-80b-a3b-instruct:free",
185
- "google/gemma-4-31b-it:free",
186
- "nvidia/nemotron-3-nano-30b-a3b:free",
187
- "z-ai/glm-4.5-air:free",
188
- "moonshotai/kimi-k2.6",
189
- "meta-llama/llama-4-maverick",
190
- "google/gemini-2.5-flash",
191
- ],
192
- openai: [
193
- "gpt-5.5",
194
- "gpt-5.5-pro",
195
- "gpt-5.4",
196
- "gpt-5.4-pro",
197
- "gpt-5.4-mini",
198
- "gpt-5.4-nano",
199
- "gpt-rosalind",
200
- "gpt-realtime-2",
201
- "openai/gpt-oss-20b",
202
- "openai/gpt-oss-120b",
203
- "gpt-5",
204
- "gpt-5-mini",
205
- "gpt-5-nano",
206
- "gpt-4o-mini",
207
- "gpt-4o",
208
- ],
209
- anthropic: [
210
- "claude-opus-4-7",
211
- "claude-sonnet-4-6",
212
- "claude-haiku-4-5",
213
- "claude-opus-4-6",
214
- "claude-sonnet-4-5",
215
- "claude-3-5-sonnet-latest",
216
- "claude-3-5-haiku-latest",
217
- ],
218
- nvidia: nvidiaFallbackModels,
219
- ollama: [
220
- "llama3.3:70b",
221
- "llama3.2:3b",
222
- "llama3.1:8b",
223
- "qwen2.5:7b",
224
- "qwen2.5-coder:7b",
225
- "deepseek-r1:7b",
226
- "mistral:7b",
227
- "gemma3:9b",
228
- "phi4:14b",
229
- "codellama:7b",
230
- ],
231
- agentrouter: [
232
- "claude-opus-4-6",
233
- "claude-opus-4-7",
234
- "claude-opus-4-8",
235
- "glm-5.2",
236
- "gpt-5.5",
237
- ],
238
- kimchi: [
239
- "kimi-k2.6",
240
- "minimax-m2.7",
241
- "nemotron-3-super-fp4",
242
- ],
243
- "aws-mantle": [],
244
- bynara: [
245
- // Free tier models
246
- "mimo-v2.5-free",
247
- "mimo-v2.5-pro-free",
248
- "mistral-large",
249
- "mistral-medium-3-5",
250
- // Pay-as-you-go / subscription models (from https://router.bynara.id/pricing)
251
- "mimo-v2.5",
252
- "mimo-v2.5-pro",
253
- "mimo-v2.5-hermes",
254
- "mimo-v2.5-pro-hermes",
255
- "mimo-v2.5-pro-ultraspeed",
256
- "claude-opus-4.7",
257
- "claude-opus-4.8",
258
- "claude-sonnet-4.6",
259
- "claude-sonnet-4.5",
260
- "claude-haiku-4.5",
261
- "deepseek-v4-flash",
262
- "deepseek-v4-pro",
263
- "gemini-3-flash",
264
- "gemini-3.1-pro",
265
- "gemini-3.5-flash",
266
- "gemini-2.5-flash",
267
- "glm-5.1",
268
- "glm-5.2",
269
- "gpt-5.4",
270
- "gpt-5.5",
271
- "kimi-k2.6",
272
- "kimi-k2.7-code",
273
- "minimax-m3",
274
- "qwen-3.7-max",
275
- "qwen-3.7-plus",
276
- "qwen-3.7-plus-1m",
277
- "bynara-max",
278
- ],
279
- };
280
- export function getKnownModels(provider) {
281
- return [...(knownModels[provider] ?? [])];
282
- }
283
- // ── Abort controller for streaming cancellation ─────────────────────────────
284
- let currentAbortController = null;
285
- class AbortRunError extends Error {
286
- constructor() {
287
- super("Aborted.");
288
- this.name = "AbortRunError";
289
- }
290
- }
29
+ import { readPromptLine, stripAnsi, isPrintableSequence, } from "./repl/prompt-line.js";
30
+ import { slashCommands, knownModels, looksLikeSlashCommand, slashCommandLabel, } from "./repl/slash-commands.js";
31
+ // Re-exported so existing imports of these names from "./repl.js" keep
32
+ // working unchanged — the command catalogue, model lists, and the line
33
+ // editor now live in their own modules under "./repl/".
34
+ export { slashCommands, knownModels, getKnownModels, looksLikeSlashCommand, getSlashCommandSuggestions, isKnownSlashCommand, } from "./repl/slash-commands.js";
35
+ export { renderSlashCommandMenu, renderFileMentionMenu, } from "./repl/prompt-line.js";
291
36
  function splitCommand(line) {
292
37
  return (line
293
38
  .match(/(?:[^\s"]+|"[^"]*")+/g)
294
39
  ?.map((part) => part.replace(/^"|"$/g, "")) ?? []);
295
40
  }
296
- function stripAnsi(text) {
297
- return text.replace(/\x1b\[[0-9;]*m/g, "");
41
+ function isAbortLikeError(error) {
42
+ if (!error)
43
+ return false;
44
+ if (typeof error === "object") {
45
+ const err = error;
46
+ if (err.name === "AbortError")
47
+ return true;
48
+ if (err.code === "ABORT_ERR")
49
+ return true;
50
+ if (typeof err.message === "string" && /abort/i.test(err.message))
51
+ return true;
52
+ }
53
+ return false;
298
54
  }
299
- /** Set of known slash-command names (without the leading "/"). */
300
- const knownSlashNames = new Set(slashCommands.map((c) => c.command.slice(1).toLowerCase()));
301
55
  /**
302
56
  * Build an OCR text layer for attached images. Some providers/proxies accept
303
57
  * multimodal `image_url` parts but silently ignore the bytes upstream — the
@@ -335,622 +89,13 @@ async function buildImageOcrGrounding(line, baseDir) {
335
89
  sections.join("\n\n") +
336
90
  "\n</image-ocr>");
337
91
  }
338
- /**
339
- * Decide whether a line that starts with "/" is actually a slash command
340
- * versus an absolute filesystem path the user typed or drag-dropped (e.g.
341
- * `/Users/me/Desktop/Screenshot.png`). A real command is "/" + a single
342
- * known command word (optionally followed by arguments). An absolute path
343
- * has extra "/" segments in its first token and won't match a known command,
344
- * so we route it to the normal prompt path where expandMentions() turns it
345
- * into a file attachment.
346
- */
347
- export function looksLikeSlashCommand(line) {
348
- if (!line.startsWith("/") || line.length < 2)
349
- return false;
350
- // First whitespace-delimited token, minus the leading slash.
351
- const firstToken = line.slice(1).split(/\s/)[0] ?? "";
352
- // A path-like first token (contains another "/" or a backslash escape, or
353
- // looks like a filename with an extension) is never a command.
354
- if (firstToken.includes("/") || firstToken.includes("\\"))
355
- return false;
356
- const name = firstToken.toLowerCase();
357
- // Exact match against a known command, or a unique prefix of one (so
358
- // partial typing like "/imp" still routes to the command handler, which
359
- // already resolves abbreviations). Unknown words like a single-segment
360
- // path token still fall through to handleSlash's "unknown command" help,
361
- // which is the historical behavior for genuine typos.
362
- if (knownSlashNames.has(name))
363
- return true;
364
- // Only treat as a (mistyped) command when it has no path/extension shape.
365
- // "Users" alone (from "/Users") would be caught above by the "/" check,
366
- // so here we accept bare alpha words as command attempts.
367
- return /^[a-z][a-z0-9-]*$/i.test(firstToken);
368
- }
369
- function isAbortLikeError(error) {
370
- if (!error)
371
- return false;
372
- if (typeof error === "object") {
373
- const err = error;
374
- if (err.name === "AbortError")
375
- return true;
376
- if (err.code === "ABORT_ERR")
377
- return true;
378
- if (typeof err.message === "string" && /abort/i.test(err.message))
379
- return true;
380
- }
381
- return false;
382
- }
383
- function slashCommandLabel(command) {
384
- return command.usage
385
- ? `${command.command} ${command.usage}`
386
- : command.command;
387
- }
388
- function slashCommandFilter(line) {
389
- // Show the menu immediately on '/' so the user can see available commands,
390
- // but let Enter submit a raw '/' unless they explicitly navigate the menu.
391
- if (!line.startsWith("/") || line.length < 1 || /\s/.test(line))
392
- return null;
393
- // Don't show the command menu for an absolute path the user is typing or
394
- // drag-dropped (e.g. "/Users/me/file.png"): a path's first token has more
395
- // "/" or backslash escapes in it. Those go to the normal prompt path.
396
- const firstToken = line.slice(1).split(/\s/)[0] ?? "";
397
- if (firstToken.includes("/") || firstToken.includes("\\"))
398
- return null;
399
- return line.slice(1).toLowerCase();
400
- }
401
- export function getSlashCommandSuggestions(line) {
402
- const filter = slashCommandFilter(line);
403
- if (filter === null)
404
- return [];
405
- return slashCommands.filter((command) => command.command.slice(1).toLowerCase().startsWith(filter));
406
- }
407
- export function isKnownSlashCommand(command) {
408
- const normalized = command.trim().split(/\s+/, 1)[0]?.toLowerCase();
409
- return slashCommands.some((item) => item.command === normalized);
410
- }
411
- function fitPlain(text, maxWidth) {
412
- if (maxWidth <= 0)
413
- return "";
414
- if (text.length <= maxWidth)
415
- return text;
416
- if (maxWidth === 1)
417
- return "…";
418
- return `${text.slice(0, maxWidth - 1)}…`;
419
- }
420
- export function renderSlashCommandMenu(line, suggestions, selectedIndex) {
421
- const cols = terminalColumns();
422
- // Stay one column short to avoid terminal autowrap when the cursor lands in
423
- // the final column. refresh() depends on each menu item occupying one row.
424
- const maxWidth = Math.max(1, cols - 1);
425
- if (suggestions.length === 0) {
426
- return [chalk.dim(fitPlain(` no commands matching ${line}`, maxWidth))];
427
- }
428
- // Cap visible items to leave room in the terminal
429
- const termRows = process.stdout.rows || 24;
430
- const maxVisible = Math.max(5, termRows - 4);
431
- const visible = suggestions.slice(0, maxVisible);
432
- const maxCommandLength = Math.max(...visible.map((command) => slashCommandLabel(command).length));
433
- const items = visible.map((command, index) => {
434
- const markerPlain = index === selectedIndex ? "›" : " ";
435
- const marker = index === selectedIndex ? chalk.magenta("›") : " ";
436
- const prefix = ` ${markerPlain} `;
437
- const labelBudget = Math.max(1, maxWidth - prefix.length);
438
- const labelWidth = Math.min(maxCommandLength + 2, labelBudget);
439
- const label = fitPlain(slashCommandLabel(command), labelWidth).padEnd(labelWidth);
440
- const descWidth = Math.max(0, maxWidth - prefix.length - label.length);
441
- const desc = fitPlain(command.description, descWidth);
442
- return ` ${marker} ${chalk.cyan(label)}${chalk.dim(desc)}`;
443
- });
444
- if (suggestions.length > maxVisible) {
445
- items.push(chalk.dim(fitPlain(` … ${suggestions.length - maxVisible} more`, maxWidth)));
446
- }
447
- return items;
448
- }
449
- export function renderFileMentionMenu(query, suggestions, selectedIndex) {
450
- const cols = terminalColumns();
451
- const maxWidth = Math.max(1, cols - 1);
452
- if (suggestions.length === 0) {
453
- return [chalk.dim(fitPlain(` no files matching @${query}`, maxWidth))];
454
- }
455
- const termRows = process.stdout.rows || 24;
456
- const maxVisible = Math.max(5, termRows - 4);
457
- const visible = suggestions.slice(0, maxVisible);
458
- const items = visible.map((suggestion, index) => {
459
- const markerPlain = index === selectedIndex ? "›" : " ";
460
- const marker = index === selectedIndex ? chalk.magenta("›") : " ";
461
- const prefix = ` ${markerPlain} `;
462
- const labelBudget = Math.max(1, maxWidth - prefix.length);
463
- const label = fitPlain(suggestion.label, labelBudget);
464
- const colored = suggestion.isDir ? chalk.cyan(label) : chalk.white(label);
465
- return ` ${marker} ${colored}`;
466
- });
467
- if (suggestions.length > maxVisible) {
468
- items.push(chalk.dim(fitPlain(` … ${suggestions.length - maxVisible} more`, maxWidth)));
469
- }
470
- return items;
471
- }
472
- function isPrintableSequence(sequence) {
473
- return sequence !== undefined && /^[^\x00-\x1f\x7f]+$/u.test(sequence);
474
- }
475
- function terminalColumns() {
476
- return Math.max(1, process.stdout.columns || 80);
477
- }
478
- function promptCursorPosition(line, cursor, columns) {
479
- const cols = Math.max(1, columns);
480
- const promptCols = promptColumnsForRender();
481
- // Walk the buffer up to the cursor, advancing visual row/col. Newlines
482
- // (from a multi-line paste) move to the next row at column 0; otherwise we
483
- // wrap when a row fills. The prompt only offsets the very first row.
484
- let row = 0;
485
- let col = promptCols < cols ? promptCols : 0;
486
- const end = Math.max(0, Math.min(cursor, line.length));
487
- for (let i = 0; i < end; i += 1) {
488
- if (line[i] === "\n") {
489
- row += 1;
490
- col = 0;
491
- continue;
492
- }
493
- col += 1;
494
- if (col >= cols) {
495
- row += 1;
496
- col = 0;
497
- }
498
- }
499
- return { row, col };
500
- }
501
- function promptColumnsForRender() {
502
- return stripAnsi(PROMPT).length;
503
- }
504
- function buildPromptRows(line, columns, includeCursorRow) {
505
- const cols = Math.max(1, columns);
506
- const promptCols = promptColumnsForRender();
507
- const rows = [];
508
- // Split on explicit newlines first (multi-line paste), then wrap each
509
- // logical line by the terminal width. Only the first logical line carries
510
- // the prompt prefix; continuation lines start at column 0.
511
- const logicalLines = line.split("\n");
512
- logicalLines.forEach((logical, li) => {
513
- const prefix = li === 0 ? PROMPT : "";
514
- const prefixCols = li === 0 ? promptCols : 0;
515
- if (prefixCols >= cols) {
516
- rows.push(prefix);
517
- for (let i = 0; i < logical.length; i += cols) {
518
- rows.push(logical.slice(i, i + cols));
519
- }
520
- }
521
- else {
522
- const firstRowCapacity = cols - prefixCols;
523
- rows.push(prefix + logical.slice(0, firstRowCapacity));
524
- for (let i = firstRowCapacity; i < logical.length; i += cols) {
525
- rows.push(logical.slice(i, i + cols));
526
- }
527
- }
528
- });
529
- if (includeCursorRow) {
530
- // Pad so the row the cursor will sit on exists even at exact width
531
- // boundaries or after a trailing newline.
532
- const cursorRows = promptCursorPosition(line, line.length, cols).row + 1;
533
- while (rows.length < cursorRows)
534
- rows.push("");
92
+ // Abort controller for streaming cancellation
93
+ let currentAbortController = null;
94
+ class AbortRunError extends Error {
95
+ constructor() {
96
+ super("Aborted.");
97
+ this.name = "AbortRunError";
535
98
  }
536
- return rows;
537
- }
538
- async function readPromptLine(options) {
539
- return new Promise((resolve) => {
540
- let line = "";
541
- let cursor = 0;
542
- let selectedIndex = 0;
543
- let menuNavigated = false;
544
- let dismissedSlashLine = null;
545
- let mentionDismissed = false;
546
- let historyIndex = null;
547
- let historyDraft = "";
548
- let lastCtrlCAt = 0;
549
- // Bracketed-paste state. When the terminal wraps a paste in
550
- // paste-start/paste-end markers, we buffer the whole paste (including
551
- // its embedded newlines) and insert it as literal text — so a multi-line
552
- // prompt is captured in full instead of the first newline submitting and
553
- // dropping the rest.
554
- let pasting = false;
555
- let pasteBuffer = "";
556
- // Track which row (relative to prompt start) the cursor is on.
557
- // Needed to move back up to prompt start when text wraps across rows.
558
- let promptCursorRow = 0;
559
- const getMenuState = () => {
560
- const filter = slashCommandFilter(line);
561
- if (filter === null || dismissedSlashLine === line) {
562
- return { visible: false, suggestions: [] };
563
- }
564
- const suggestions = getSlashCommandSuggestions(line);
565
- if (selectedIndex >= suggestions.length)
566
- selectedIndex = 0;
567
- return { visible: true, suggestions };
568
- };
569
- // File @-mention autocomplete: active when the cursor sits inside an
570
- // `@partial/path` token. Mutually exclusive with the slash menu (slash
571
- // requires the line to start with "/" and contain no whitespace).
572
- const getMentionState = () => {
573
- if (mentionDismissed || line.startsWith("/")) {
574
- return { visible: false, query: "", start: 0, suggestions: [] };
575
- }
576
- const q = getMentionQuery(line, cursor);
577
- if (!q)
578
- return { visible: false, query: "", start: 0, suggestions: [] };
579
- const suggestions = findFileSuggestions(q.query);
580
- if (suggestions.length === 0) {
581
- return { visible: false, query: q.query, start: q.start, suggestions };
582
- }
583
- if (selectedIndex >= suggestions.length)
584
- selectedIndex = 0;
585
- return { visible: true, query: q.query, start: q.start, suggestions };
586
- };
587
- const applyMention = (suggestion, start) => {
588
- const before = line.slice(0, start);
589
- const after = line.slice(cursor);
590
- let insert = `@${suggestion.value}`;
591
- let newCursor = before.length + insert.length;
592
- if (!suggestion.isDir) {
593
- // Completed a file — add a trailing space and close the menu so the
594
- // user can keep typing their request.
595
- insert += " ";
596
- newCursor = before.length + insert.length;
597
- mentionDismissed = true;
598
- }
599
- else {
600
- // Completed a directory — keep the menu open so the user drills in.
601
- mentionDismissed = false;
602
- }
603
- line = before + insert + after;
604
- cursor = newCursor;
605
- selectedIndex = 0;
606
- menuNavigated = false;
607
- refresh();
608
- };
609
- const refresh = () => {
610
- const cols = terminalColumns();
611
- const menu = getMenuState();
612
- const mention = menu.visible
613
- ? {
614
- visible: false,
615
- query: "",
616
- start: 0,
617
- suggestions: [],
618
- }
619
- : getMentionState();
620
- const menuLines = menu.visible
621
- ? renderSlashCommandMenu(line, menu.suggestions, selectedIndex)
622
- : mention.visible
623
- ? renderFileMentionMenu(mention.query, mention.suggestions, selectedIndex)
624
- : [];
625
- const promptRows = buildPromptRows(line, cols, true);
626
- const target = promptCursorPosition(line, cursor, cols);
627
- const blockRows = [...promptRows, ...menuLines];
628
- // Always redraw the whole prompt block from its anchor. Partial row
629
- // clearing is fragile once terminal autowrap, slash menus, and cursor
630
- // movement mix; clearing to the end of screen leaves no stale wrapped
631
- // prompt/menu rows behind and keeps the cursor anchor stable.
632
- if (promptCursorRow > 0) {
633
- moveCursor(output, 0, -promptCursorRow);
634
- }
635
- cursorTo(output, 0);
636
- output.write("\x1b[J");
637
- output.write(blockRows.join("\n"));
638
- const currentRow = Math.max(0, blockRows.length - 1);
639
- const rowDelta = target.row - currentRow;
640
- if (rowDelta !== 0) {
641
- moveCursor(output, 0, rowDelta);
642
- }
643
- cursorTo(output, target.col);
644
- promptCursorRow = target.row;
645
- };
646
- const editLine = (nextLine, nextCursor) => {
647
- line = nextLine;
648
- cursor = Math.max(0, Math.min(nextCursor, line.length));
649
- selectedIndex = 0;
650
- menuNavigated = false;
651
- dismissedSlashLine = null;
652
- mentionDismissed = false;
653
- historyIndex = null;
654
- refresh();
655
- };
656
- // Insert literal text (e.g. a multi-line paste) at the cursor, preserving
657
- // newlines so the full pasted prompt is kept and later submitted intact.
658
- const insertText = (text) => {
659
- const normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
660
- if (!normalized)
661
- return;
662
- line = line.slice(0, cursor) + normalized + line.slice(cursor);
663
- cursor += normalized.length;
664
- selectedIndex = 0;
665
- menuNavigated = false;
666
- dismissedSlashLine = null;
667
- mentionDismissed = false;
668
- historyIndex = null;
669
- refresh();
670
- };
671
- const cleanup = (restoreInput = false) => {
672
- input.off("keypress", handleKeypress);
673
- // Leave bracketed-paste mode so the terminal isn't left in a special
674
- // state during tool execution or after exit.
675
- if (input.isTTY)
676
- output.write("\x1b[?2004l");
677
- if (restoreInput) {
678
- input.pause();
679
- if (input.isTTY)
680
- input.setRawMode(false);
681
- }
682
- };
683
- const clearPromptDisplay = () => {
684
- // Move back to prompt start
685
- if (promptCursorRow > 0) {
686
- moveCursor(output, 0, -promptCursorRow);
687
- }
688
- cursorTo(output, 0);
689
- output.write("\x1b[J");
690
- promptCursorRow = 0;
691
- };
692
- const submit = (submittedLine) => {
693
- line = submittedLine;
694
- cursor = line.length;
695
- // Move back to prompt start
696
- if (promptCursorRow > 0) {
697
- moveCursor(output, 0, -promptCursorRow);
698
- }
699
- cursorTo(output, 0);
700
- output.write("\x1b[J");
701
- // Write final prompt using the same wrapping rules as refresh(), then
702
- // move to the next line. Do not include the extra cursor-only row used
703
- // for interactive editing at exact terminal-width boundaries.
704
- output.write(buildPromptRows(line, terminalColumns(), false).join("\n"));
705
- output.write("\n");
706
- promptCursorRow = 0;
707
- cleanup();
708
- resolve(submittedLine);
709
- };
710
- function handleKeypress(sequence, key) {
711
- // The alt-screen pager owns the terminal while open; ignore everything
712
- // here so the user's navigation keys don't bleed into the input line.
713
- if (isPagerActive())
714
- return;
715
- // ── Bracketed paste ───────────────────────────────────────────────
716
- // The terminal brackets a paste with paste-start / paste-end markers.
717
- // Buffer everything in between (including embedded newlines) and insert
718
- // it literally, so a long multi-line prompt is captured in full and the
719
- // first newline does not submit early.
720
- if (key?.name === "paste-start") {
721
- pasting = true;
722
- pasteBuffer = "";
723
- return;
724
- }
725
- if (key?.name === "paste-end") {
726
- pasting = false;
727
- const pasted = pasteBuffer;
728
- pasteBuffer = "";
729
- if (pasted)
730
- insertText(pasted);
731
- return;
732
- }
733
- if (pasting) {
734
- if (key?.name === "return" || key?.name === "enter") {
735
- pasteBuffer += "\n";
736
- }
737
- else if (key?.name === "tab") {
738
- pasteBuffer += "\t";
739
- }
740
- else if (isPrintableSequence(sequence)) {
741
- pasteBuffer += sequence;
742
- }
743
- return;
744
- }
745
- const menu = getMenuState();
746
- const mention = menu.visible
747
- ? {
748
- visible: false,
749
- query: "",
750
- start: 0,
751
- suggestions: [],
752
- }
753
- : getMentionState();
754
- // Cmd+C on macOS terminals is handled by the OS (it never reaches us),
755
- // but some Linux terminals forward Meta+C. Treat that as a no-op so
756
- // selecting + copying never breaks the REPL.
757
- if (key.meta && !key.ctrl && key.name === "c")
758
- return;
759
- if (isCtrlC(key)) {
760
- // First press: clear the current line. Second press within 1s: exit.
761
- // This mirrors bash / Claude Code and avoids killing the REPL by
762
- // accident when users habitually press Ctrl+C to copy in some
763
- // terminals.
764
- const now = Date.now();
765
- if (line.length > 0) {
766
- editLine("", 0);
767
- lastCtrlCAt = now;
768
- return;
769
- }
770
- if (now - lastCtrlCAt < 1_000) {
771
- cleanup(true);
772
- output.write("\n");
773
- process.exit(0);
774
- }
775
- lastCtrlCAt = now;
776
- output.write("\n");
777
- output.write(chalk.dim(" (press Ctrl+C again to exit)\n"));
778
- output.write(PROMPT);
779
- return;
780
- }
781
- if (isCtrlT(key)) {
782
- options.onThinkingShortcut();
783
- refresh();
784
- return;
785
- }
786
- if (isCtrlO(key)) {
787
- clearPromptDisplay();
788
- output.write("\n");
789
- void options.onOutputShortcut().finally(refresh);
790
- return;
791
- }
792
- if (isCtrlP(key)) {
793
- clearPromptDisplay();
794
- output.write("\n");
795
- void options.onPlanShortcut().finally(refresh);
796
- return;
797
- }
798
- if (key.name === "return" || key.name === "enter") {
799
- if (mention.visible && mention.suggestions.length > 0) {
800
- applyMention(mention.suggestions[selectedIndex] ?? mention.suggestions[0], mention.start);
801
- return;
802
- }
803
- const useSelection = menu.visible && (line !== "/" || menuNavigated);
804
- const selectedCommand = useSelection
805
- ? menu.suggestions[selectedIndex]
806
- : undefined;
807
- submit(selectedCommand?.command ?? line);
808
- return;
809
- }
810
- if (key.name === "tab") {
811
- if (mention.visible && mention.suggestions.length > 0) {
812
- applyMention(mention.suggestions[selectedIndex] ?? mention.suggestions[0], mention.start);
813
- return;
814
- }
815
- if (menu.visible && menu.suggestions.length > 0) {
816
- const target = menu.suggestions[selectedIndex] ?? menu.suggestions[0];
817
- editLine(target.command, target.command.length);
818
- }
819
- return;
820
- }
821
- if (isEscape(key)) {
822
- if (mention.visible) {
823
- mentionDismissed = true;
824
- refresh();
825
- return;
826
- }
827
- if (menu.visible) {
828
- dismissedSlashLine = line;
829
- refresh();
830
- }
831
- return;
832
- }
833
- if (key.name === "up") {
834
- if (mention.visible && mention.suggestions.length > 0) {
835
- selectedIndex =
836
- (selectedIndex - 1 + mention.suggestions.length) %
837
- mention.suggestions.length;
838
- menuNavigated = true;
839
- refresh();
840
- return;
841
- }
842
- if (menu.visible && menu.suggestions.length > 0) {
843
- selectedIndex =
844
- (selectedIndex - 1 + menu.suggestions.length) %
845
- menu.suggestions.length;
846
- menuNavigated = true;
847
- refresh();
848
- return;
849
- }
850
- if (options.history.length > 0) {
851
- if (historyIndex === null) {
852
- historyDraft = line;
853
- historyIndex = options.history.length - 1;
854
- }
855
- else {
856
- historyIndex = Math.max(0, historyIndex - 1);
857
- }
858
- line = options.history[historyIndex] ?? "";
859
- cursor = line.length;
860
- selectedIndex = 0;
861
- dismissedSlashLine = null;
862
- refresh();
863
- }
864
- return;
865
- }
866
- if (key.name === "down") {
867
- if (mention.visible && mention.suggestions.length > 0) {
868
- selectedIndex = (selectedIndex + 1) % mention.suggestions.length;
869
- menuNavigated = true;
870
- refresh();
871
- return;
872
- }
873
- if (menu.visible && menu.suggestions.length > 0) {
874
- selectedIndex = (selectedIndex + 1) % menu.suggestions.length;
875
- menuNavigated = true;
876
- refresh();
877
- return;
878
- }
879
- if (historyIndex !== null) {
880
- if (historyIndex < options.history.length - 1) {
881
- historyIndex += 1;
882
- line = options.history[historyIndex] ?? "";
883
- }
884
- else {
885
- historyIndex = null;
886
- line = historyDraft;
887
- }
888
- cursor = line.length;
889
- selectedIndex = 0;
890
- dismissedSlashLine = null;
891
- refresh();
892
- }
893
- return;
894
- }
895
- if (key.name === "left") {
896
- if (cursor > 0) {
897
- cursor -= 1;
898
- refresh();
899
- }
900
- return;
901
- }
902
- if (key.name === "right") {
903
- if (cursor < line.length) {
904
- cursor += 1;
905
- refresh();
906
- }
907
- return;
908
- }
909
- if (key.name === "home" || (key.ctrl && key.name === "a")) {
910
- cursor = 0;
911
- refresh();
912
- return;
913
- }
914
- if (key.name === "end" || (key.ctrl && key.name === "e")) {
915
- cursor = line.length;
916
- refresh();
917
- return;
918
- }
919
- if (key.name === "backspace") {
920
- if (cursor > 0) {
921
- editLine(line.slice(0, cursor - 1) + line.slice(cursor), cursor - 1);
922
- }
923
- return;
924
- }
925
- if (key.name === "delete") {
926
- if (cursor < line.length) {
927
- editLine(line.slice(0, cursor) + line.slice(cursor + 1), cursor);
928
- }
929
- return;
930
- }
931
- if (key.ctrl && key.name === "u") {
932
- editLine(line.slice(cursor), 0);
933
- return;
934
- }
935
- if (key.ctrl && key.name === "k") {
936
- editLine(line.slice(0, cursor), cursor);
937
- return;
938
- }
939
- if (isPrintableSequence(sequence) && !key.ctrl && !key.meta) {
940
- editLine(line.slice(0, cursor) + sequence + line.slice(cursor), cursor + sequence.length);
941
- }
942
- }
943
- output.write(PROMPT);
944
- if (input.isTTY) {
945
- input.setRawMode(true);
946
- // Enable bracketed paste so multi-line pastes arrive as one chunk
947
- // (wrapped in paste-start/paste-end) instead of submitting at the
948
- // first embedded newline.
949
- output.write("\x1b[?2004h");
950
- }
951
- input.resume();
952
- input.on("keypress", handleKeypress);
953
- });
954
99
  }
955
100
  // Build a concise, width-bounded spinner label for ask-mode research so the
956
101
  // user sees what's being searched/fetched without the line overflowing.
@@ -2051,16 +1196,15 @@ async function handleSlash(line, state) {
2051
1196
  clearThinking();
2052
1197
  // Clear the entire screen and move cursor to top
2053
1198
  process.stdout.write("\x1b[2J\x1b[3J\x1b[H");
2054
- // Re-render the startup banner
2055
- console.log(renderBanner(getCurrentVersion()));
2056
- console.log(renderSessionInfo({
1199
+ // Re-render the startup intro card
1200
+ console.log(renderIntroCard({
1201
+ version: getCurrentVersion(),
2057
1202
  workdir: safeCwd(),
2058
1203
  model: state.model,
2059
1204
  provider: state.provider,
2060
1205
  mode: state.mode,
2061
1206
  }));
2062
- console.log(renderSuggestions());
2063
- console.log(chalk.dim(" ESC abort │ Ctrl+C clears input │ @ to attach files │ Ctrl+T thinking │ Ctrl+O tool output │ Ctrl+P plan (q to close)\n"));
1207
+ console.log();
2064
1208
  return true;
2065
1209
  }
2066
1210
  case "/update":
@@ -2111,7 +1255,7 @@ export async function startRepl(options = {}) {
2111
1255
  };
2112
1256
  process.on("unhandledRejection", handleUnhandledRejection);
2113
1257
  process.on("uncaughtException", handleUncaughtException);
2114
- // ── ESC / Ctrl+C abort; Ctrl+T toggles hidden thinking ──────────────────
1258
+ // ESC / Ctrl+C abort; Ctrl+T toggles hidden thinking
2115
1259
  if (process.stdin.isTTY) {
2116
1260
  process.stdin.setRawMode(false);
2117
1261
  }
@@ -2243,16 +1387,15 @@ export async function startRepl(options = {}) {
2243
1387
  console.log(chalk.dim("\n (press Ctrl+C again to exit)"));
2244
1388
  };
2245
1389
  process.on("SIGINT", handleSigint);
2246
- // ── Startup banner ──────────────────────────────────────────────────────
2247
- console.log(renderBanner(getCurrentVersion()));
2248
- console.log(renderSessionInfo({
1390
+ // Startup intro card
1391
+ console.log(renderIntroCard({
1392
+ version: getCurrentVersion(),
2249
1393
  workdir: safeCwd(),
2250
1394
  model: state.model,
2251
1395
  provider: state.provider,
2252
1396
  mode: state.mode,
2253
1397
  }));
2254
- console.log(renderSuggestions());
2255
- console.log(chalk.dim(" ESC abort │ Ctrl+C clears input │ @ to attach files │ Ctrl+T thinking │ Ctrl+O tool output │ Ctrl+P plan (q to close)\n"));
1398
+ console.log();
2256
1399
  // Hint thinking-capable users that the toggle exists. We default it to
2257
1400
  // off for speed, since on NIM many models route through a much slower
2258
1401
  // chat-template path when reasoning is enabled.
@@ -2279,7 +1422,7 @@ export async function startRepl(options = {}) {
2279
1422
  isReadingPrompt = false;
2280
1423
  if (!line)
2281
1424
  continue;
2282
- // ── /implement — approve the active plan and execute it ──────────
1425
+ // /implement — approve the active plan and execute it
2283
1426
  // Handled here (not in handleSlash) because it must trigger a full
2284
1427
  // agent run with the plan marked approved, not just print something.
2285
1428
  let implementApproved = false;
@@ -2310,7 +1453,7 @@ export async function startRepl(options = {}) {
2310
1453
  "Do NOT call web.search — you already know everything needed. " +
2311
1454
  "Run real commands (installs, servers, verification) — do not claim anything ran without a successful tool call.";
2312
1455
  }
2313
- // ── Continuation detection ──────────────────────────────────────────
1456
+ // Continuation detection
2314
1457
  // When the user types "continue" after the agent paused at a step limit,
2315
1458
  // augment the bare keyword with rich resumption context so the model
2316
1459
  // picks up from where it left off instead of restarting.