kritya 0.8.24-beta → 0.8.25-beta

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 CHANGED
@@ -65,6 +65,9 @@ Requires Node.js >=22.
65
65
  - Linux/macOS: `export NVIDIA_API_KEY=nvapi-...`
66
66
  - Windows: `setx NVIDIA_API_KEY nvapi-...` (then open a new terminal)
67
67
 
68
+ Skip this and kritya will prompt for the key on first launch (interactive
69
+ mode only) and save it to `~/.kritya/.env` for you.
70
+
68
71
  Optional: add `TAVILY_API_KEY` the same way to enable the agent's
69
72
  web-search tool (`/web-search`) — get one at [tavily.com](https://tavily.com).
70
73
 
@@ -159,6 +162,7 @@ In-session commands (type `/` to see them with autocomplete; letters filter the
159
162
  | `/cost` | token usage and estimated $ (see Pricing below) |
160
163
  | `/audit` | show this session's permission decisions and verify the audit log's chain ([more](docs/CONFIGURATION.md#audit-log--telemetry)) |
161
164
  | `/budget` | show session token budget; `/budget reset` or `/budget <number>` |
165
+ | `/status` | show context/budget/token/task detail left off the always-visible status line |
162
166
  | `/kill` | emergency stop: `/kill [reason]` halts everything; `/kill off` releases |
163
167
  | `/help` | command list |
164
168
  | `/exit` | quit |
@@ -282,8 +286,8 @@ full tool output. `Ctrl+K` is the kill switch (see below). `Ctrl+C` exits.
282
286
  message is queued and absorbed before its next step (no need to interrupt).
283
287
  - **Auto-compaction → self-improving project memory** — when the conversation
284
288
  nears the model's context window (80% of `contextWindow`, default 120k
285
- tokens), older turns are summarized automatically; the statusline shows
286
- current usage as `ctx N%`. Compaction (auto or manual `/compact`) also
289
+ tokens), older turns are summarized automatically; `/status` shows current
290
+ usage as `context used: N%`. Compaction (auto or manual `/compact`) also
287
291
  distills durable, objective facts out of what's being summarized away —
288
292
  build/test commands, package manager, conventions actually observed — and
289
293
  merges any new ones into a `## Learned by kritya` section in `KRITYA.md`,
@@ -294,7 +298,8 @@ full tool output. `Ctrl+K` is the kill switch (see below). `Ctrl+C` exits.
294
298
  - **Token budget** — a session-wide cap on combined prompt + completion
295
299
  tokens across every turn and model (default 1,000,000; set `tokenBudget` in
296
300
  config, or `/budget <number>` mid-session). The statusline shows `budget N%`
297
- once usage starts, turning yellow past 80% with a one-time warning, then
301
+ once usage crosses 80% (yellow, with a one-time warning) or the budget is
302
+ hit (red); `/status` shows it from the start. It then
298
303
  stops further turns entirely at 100% until you run `/budget reset` (clears
299
304
  the count) or `/budget <number>` (raises the cap). `/cost` also reports it.
300
305
  - **Background processes** — the agent can start dev servers/watchers with
@@ -311,7 +316,7 @@ full tool output. `Ctrl+K` is the kill switch (see below). `Ctrl+C` exits.
311
316
  every request.
312
317
  - **Sub-task checklist → resumable plans** — for multi-step requests the agent
313
318
  plans first and shows a live ☐/◐/☑ checklist as it works, with a compact
314
- `tasks N/M` summary in the statusline. The checklist is saved alongside the
319
+ `tasks N/M` summary available via `/status`. The checklist is saved alongside the
315
320
  session, so `kritya -c` (and picking a session via `-r`) restores not just
316
321
  the conversation but exactly which steps were done, in progress, or still
317
322
  pending.
@@ -346,7 +351,7 @@ full tool output. `Ctrl+K` is the kill switch (see below). `Ctrl+C` exits.
346
351
  - **Prompt-caching awareness** — the system prompt is ordered stable-first
347
352
  (identity and rules → project memory → volatile git status/listing last) so
348
353
  providers can reuse their cached prompt prefix across turns instead of
349
- re-reading everything. `/cost` and the statusline show how many prompt
354
+ re-reading everything. `/cost` and `/status` show how many prompt
350
355
  tokens were served from the provider's cache; add an optional
351
356
  `"cachedInput"` rate to your `pricing` config to see the dollar savings.
352
357
  - **LSP integration** — the agent gets go-to-definition, find-references,
@@ -9,73 +9,138 @@ import { scanPluginMcpServers } from "../plugins/mcp.js";
9
9
  import { listProviders } from "../config/config.js";
10
10
  import { expandCommand } from "./custom.js";
11
11
  import { artifactPath, clearProjectState, loadProjectState, parseIdea, parsePhase, phaseBlocker, phasePrompt, renameProject, staleArtifacts, PHASE_COMMAND, PHASE_ORDER, PHASE_SUMMARY, saveProjectState, slugify, } from "../agent/workflow.js";
12
+ const WORKFLOW = "Workflow";
13
+ const SESSION = "Session";
14
+ const SAFETY = "Safety & limits";
15
+ const INFO = "Info";
12
16
  export const BUILTIN_COMMANDS = [
13
- { name: "/help", description: "show available commands" },
14
- { name: "/model", description: "pick a model, or /model <id> for any provider model ID" },
15
- {
16
- name: "/provider",
17
- description: "list providers, or /provider <name> to switch mid-session (keeps history); add --default to persist",
18
- },
19
17
  {
20
18
  name: "/flow-brainstorm",
21
19
  description: "start a new-project workflow: /flow-brainstorm <idea> (brainstorm→spec→plan→build→review→fix)",
20
+ category: WORKFLOW,
22
21
  },
23
22
  {
24
23
  name: "/flow-spec",
25
24
  description: "project workflow: write the spec from the approved brainstorm",
25
+ category: WORKFLOW,
26
+ },
27
+ {
28
+ name: "/flow-plan",
29
+ description: "project workflow: plan from the spec (read-only)",
30
+ category: WORKFLOW,
31
+ },
32
+ {
33
+ name: "/flow-build",
34
+ description: "project workflow: implement the plan, with tests",
35
+ category: WORKFLOW,
36
+ },
37
+ {
38
+ name: "/flow-review",
39
+ description: "project workflow: spec-compliance and security review",
40
+ category: WORKFLOW,
41
+ },
42
+ {
43
+ name: "/flow-fix",
44
+ description: "project workflow: fix the review's findings, re-verified",
45
+ category: WORKFLOW,
26
46
  },
27
- { name: "/plan", description: "toggle plan mode: /plan, /plan on, /plan off" },
28
- { name: "/flow-plan", description: "project workflow: plan from the spec (read-only)" },
29
- { name: "/flow-build", description: "project workflow: implement the plan, with tests" },
30
- { name: "/flow-review", description: "project workflow: spec-compliance and security review" },
31
- { name: "/flow-fix", description: "project workflow: fix the review's findings, re-verified" },
32
47
  {
33
48
  name: "/project",
34
49
  description: "workflow status · /project goto <phase> · rename <name> · clear",
50
+ category: WORKFLOW,
35
51
  },
36
- { name: "/diff", description: "show the cumulative git diff of this session's changes" },
37
- { name: "/redo", description: "reapply the change most recently undone" },
38
- { name: "/init", description: "scan the repo and generate a KRITYA.md project-memory file" },
39
- { name: "/web-search", description: "search the web: /web-search <query>" },
40
52
  {
41
- name: "/mcp",
42
- description: "MCP servers: status, /mcp add|remove <name>, /mcp login|logout <name>",
53
+ name: "/model",
54
+ description: "pick a model, or /model <id> for any provider model ID",
55
+ category: SESSION,
43
56
  },
44
57
  {
45
- name: "/skills",
46
- description: "list discovered skills (project + user-global) and why any were skipped",
58
+ name: "/provider",
59
+ description: "list providers, or /provider <name> to switch mid-session (keeps history); add --default to persist",
60
+ category: SESSION,
47
61
  },
48
62
  {
49
- name: "/plugins",
50
- description: "list discovered Agent Plugins, what each contributes, and why any were skipped",
63
+ name: "/diff",
64
+ description: "show the cumulative git diff of this session's changes",
65
+ category: SESSION,
66
+ },
67
+ {
68
+ name: "/init",
69
+ description: "scan the repo and generate a KRITYA.md project-memory file",
70
+ category: SESSION,
51
71
  },
52
- { name: "/undo", description: "revert the file changes from the agent's last turn" },
72
+ { name: "/web-search", description: "search the web: /web-search <query>", category: SESSION },
73
+ {
74
+ name: "/commit",
75
+ description: "have the agent stage and commit the current changes",
76
+ category: SESSION,
77
+ },
78
+ {
79
+ name: "/compact",
80
+ description: "summarize older conversation to free context space",
81
+ category: SESSION,
82
+ },
83
+ { name: "/clear", description: "start a fresh conversation", category: SESSION },
84
+ {
85
+ name: "/undo",
86
+ description: "revert the file changes from the agent's last turn",
87
+ category: SESSION,
88
+ },
89
+ { name: "/redo", description: "reapply the change most recently undone", category: SESSION },
53
90
  {
54
91
  name: "/checkpoint",
55
92
  description: "save a named point: /checkpoint <name> (no name lists saved ones)",
93
+ category: SESSION,
56
94
  },
57
95
  {
58
96
  name: "/rewind",
59
97
  description: "rewind the conversation and files to a checkpoint: /rewind <name>",
98
+ category: SESSION,
99
+ },
100
+ {
101
+ name: "/plan",
102
+ description: "toggle plan mode (read-only): /plan, /plan on, /plan off",
103
+ category: SAFETY,
104
+ },
105
+ {
106
+ name: "/kill",
107
+ description: "emergency stop: /kill [reason] halts everything · /kill off releases (Ctrl+K)",
108
+ category: SAFETY,
60
109
  },
61
- { name: "/commit", description: "have the agent stage and commit the current changes" },
62
- { name: "/compact", description: "summarize older conversation to free context space" },
63
- { name: "/clear", description: "start a fresh conversation" },
64
- { name: "/cost", description: "show token usage and estimated cost" },
65
110
  {
66
111
  name: "/audit",
67
112
  description: "show this session's permission decisions and verify the audit log's chain",
113
+ category: SAFETY,
68
114
  },
69
115
  {
70
116
  name: "/budget",
71
117
  description: "show session token budget, /budget reset, or /budget <number> to set it",
118
+ category: SAFETY,
72
119
  },
120
+ { name: "/cost", description: "show token usage and estimated cost", category: SAFETY },
73
121
  {
74
- name: "/kill",
75
- description: "emergency stop: /kill [reason] halts everything · /kill off releases (Ctrl+K)",
122
+ name: "/status",
123
+ description: "show context/budget/token/task detail left off the status line",
124
+ category: SAFETY,
125
+ },
126
+ { name: "/help", description: "show available commands", category: INFO },
127
+ {
128
+ name: "/skills",
129
+ description: "list discovered skills (project + user-global) and why any were skipped",
130
+ category: INFO,
131
+ },
132
+ {
133
+ name: "/plugins",
134
+ description: "list discovered Agent Plugins, what each contributes, and why any were skipped",
135
+ category: INFO,
136
+ },
137
+ {
138
+ name: "/mcp",
139
+ description: "MCP servers: status, /mcp add|remove <name>, /mcp login|logout <name>",
140
+ category: INFO,
76
141
  },
77
- { name: "/exit", description: "leave" },
78
- { name: "/quit", description: "leave" },
142
+ { name: "/exit", description: "leave", category: INFO },
143
+ { name: "/quit", description: "leave", category: INFO },
79
144
  ];
80
145
  export const HELP_TEXT = `Commands:
81
146
  ${BUILTIN_COMMANDS.map((c) => ` ${c.name.padEnd(14)} ${c.description}`).join("\n")}
@@ -379,6 +444,9 @@ const handlers = {
379
444
  "/cost": (ctx) => {
380
445
  ctx.addItem({ kind: "info", text: ctx.costReport() });
381
446
  },
447
+ "/status": (ctx) => {
448
+ ctx.addItem({ kind: "info", text: ctx.statusReport() });
449
+ },
382
450
  "/audit": (ctx) => {
383
451
  const audit = ctx.agent.audit;
384
452
  if (!audit) {
@@ -674,6 +742,7 @@ const ALLOWED_WHILE_KILLED = new Set([
674
742
  "/quit",
675
743
  "/audit",
676
744
  "/cost",
745
+ "/status",
677
746
  "/diff",
678
747
  "/budget",
679
748
  "/mcp",
@@ -165,6 +165,7 @@ export function resolveProvider(config, override) {
165
165
  name,
166
166
  baseUrl,
167
167
  apiKey,
168
+ apiKeyEnv: merged.apiKeyEnv,
168
169
  temperature: merged.temperature,
169
170
  topP: merged.topP,
170
171
  maxTokens: merged.maxTokens,
@@ -322,3 +323,17 @@ export function loadDotEnv(paths) {
322
323
  }
323
324
  }
324
325
  }
326
+ /**
327
+ * Append one KEY=VALUE line to ~/.kritya/.env, creating the directory (and
328
+ * hardening its permissions) the same way saveConfig does for config.json.
329
+ * Used by the first-run API key setup prompt so a pasted key survives across
330
+ * restarts without the user having to edit a dotfile by hand.
331
+ */
332
+ export function appendGlobalEnvVar(name, value) {
333
+ fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
334
+ hardenWindowsDir(CONFIG_DIR);
335
+ const envFile = path.join(CONFIG_DIR, ".env");
336
+ const sanitized = value.replace(/[\r\n]/g, "");
337
+ fs.appendFileSync(envFile, `${name}=${sanitized}\n`, { mode: 0o600 });
338
+ fs.chmodSync(envFile, 0o600);
339
+ }
@@ -76,3 +76,13 @@ export function modelDisplaySlug(modelId) {
76
76
  .replace(/^-+|-+$/g, "");
77
77
  return `${prefix}${slug}`;
78
78
  }
79
+ /**
80
+ * "provider/model" for display, without the doubled prefix that comes from
81
+ * naively joining them: several providers' model IDs already carry their own
82
+ * namespace (NVIDIA's catalog is "nvidia/nemotron-...", so the "nvidia"
83
+ * provider plus that model id would otherwise print "nvidia/nvidia/...").
84
+ * Only skip the provider prefix when the model id already starts with it.
85
+ */
86
+ export function displayModelId(providerName, modelId) {
87
+ return modelId.startsWith(`${providerName}/`) ? modelId : `${providerName}/${modelId}`;
88
+ }
package/dist/headless.js CHANGED
@@ -5,7 +5,7 @@ import { CONFIG_DIR, legacyGlobalModel, listProviders, loadConfig, loadDotEnv, p
5
5
  import { DEFAULT_MODEL, contextWindowFor } from "./config/models.js";
6
6
  import { PermissionManager } from "./permissions/permissions.js";
7
7
  import { loadRules } from "./permissions/rules.js";
8
- import { ProviderClient, RetryExhaustedError } from "./provider/client.js";
8
+ import { ProviderClient, RetryExhaustedError, friendlyProviderErrorHint, } from "./provider/client.js";
9
9
  import { createSwitchyardClient } from "./provider/switchyardClient.js";
10
10
  import { SWITCHYARD_ROUTE_ID, resolveEffectiveModel, staleSwitchyardModelWarning, stopSwitchyardSidecar, } from "./provider/switchyardSidecar.js";
11
11
  import { SessionStore } from "./session/store.js";
@@ -257,7 +257,9 @@ export async function runHeadless(args) {
257
257
  errorMessage = `Stopped (${err.killReason ?? "kill switch engaged"})`;
258
258
  }
259
259
  else {
260
- errorMessage = err instanceof Error ? err.message : String(err);
260
+ const message = err instanceof Error ? err.message : String(err);
261
+ const friendlyHint = friendlyProviderErrorHint(err, provider.name);
262
+ errorMessage = friendlyHint ? `${friendlyHint} (${message})` : message;
261
263
  }
262
264
  }
263
265
  finally {
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@ import fs from "node:fs";
4
4
  import path from "node:path";
5
5
  import { render } from "ink";
6
6
  import { Agent } from "./agent/loop.js";
7
- import { CONFIG_DIR, legacyGlobalModel, loadConfig, loadDotEnv, privacyModeFor, resolveProvider, } from "./config/config.js";
7
+ import { CONFIG_DIR, appendGlobalEnvVar, legacyGlobalModel, loadConfig, loadDotEnv, privacyModeFor, resolveProvider, } from "./config/config.js";
8
8
  import { DEFAULT_MODEL, contextWindowFor } from "./config/models.js";
9
9
  import { PermissionManager } from "./permissions/permissions.js";
10
10
  import { loadRules } from "./permissions/rules.js";
@@ -26,6 +26,7 @@ import { UndoStack } from "./undo/undo.js";
26
26
  import { App } from "./ui/App.js";
27
27
  import { TrustPrompt } from "./ui/TrustPrompt.js";
28
28
  import { AiDisclosurePrompt } from "./ui/AiDisclosurePrompt.js";
29
+ import { ApiKeySetupPrompt } from "./ui/ApiKeySetupPrompt.js";
29
30
  import { McpTrustPrompt } from "./ui/McpTrustPrompt.js";
30
31
  import { loadHooks, HookRunner } from "./hooks/hooks.js";
31
32
  import { loadMcpTools, shutdownMcp, } from "./mcp/client.js";
@@ -35,6 +36,7 @@ import { pluginsDir, scanPlugins, userPluginsDir } from "./plugins/discover.js";
35
36
  import { loadPluginMcpServers } from "./plugins/mcp.js";
36
37
  import { describeGatedContent, gatedContentHash, isTrusted, saveTrust } from "./trust/trust.js";
37
38
  import { isAiDisclosureShown, markAiDisclosureShown } from "./trust/aiDisclosure.js";
39
+ import { isBannerSeen, markBannerSeen } from "./trust/bannerSeen.js";
38
40
  import { partitionByTrust, serverFingerprint, trustServer } from "./trust/mcpTrust.js";
39
41
  import { runHeadless } from "./headless.js";
40
42
  import { installCrashHandlers } from "./crash.js";
@@ -264,9 +266,27 @@ async function showAiDisclosureNotice() {
264
266
  } }));
265
267
  });
266
268
  }
269
+ /**
270
+ * Interactive fallback when no API key resolved for the active provider.
271
+ * Lets the user paste one straight in, saving it to ~/.kritya/.env, rather
272
+ * than forcing them out to read env-var instructions and relaunch. Only
273
+ * called from the interactive TTY path (see main() below) — headless mode
274
+ * (src/headless.ts) never imports this and keeps failing with a plain error.
275
+ */
276
+ async function promptApiKeySetup(providerName, envVarName) {
277
+ if (!envVarName)
278
+ return null;
279
+ return new Promise((resolve) => {
280
+ const instance = render(_jsx(ApiKeySetupPrompt, { providerName: providerName, envVarName: envVarName, onDecision: (result) => {
281
+ instance.unmount();
282
+ resolve(result.action === "save" ? result.key : null);
283
+ } }));
284
+ });
285
+ }
267
286
  async function main() {
268
287
  const config = loadConfig();
269
288
  const privacyMode = args.privacy || privacyModeFor(config);
289
+ const firstLaunch = !isBannerSeen(workspace);
270
290
  if (!isAiDisclosureShown(workspace)) {
271
291
  await showAiDisclosureNotice();
272
292
  markAiDisclosureShown(workspace);
@@ -279,7 +299,15 @@ async function main() {
279
299
  loadDotEnv([path.join(workspace, ".env")]);
280
300
  }
281
301
  const provider = resolveProvider(config, args.provider || undefined);
282
- const apiKey = provider.apiKey;
302
+ let apiKey = provider.apiKey;
303
+ if (!apiKey) {
304
+ const entered = await promptApiKeySetup(provider.name, provider.apiKeyEnv);
305
+ if (entered) {
306
+ appendGlobalEnvVar(provider.apiKeyEnv, entered);
307
+ process.env[provider.apiKeyEnv] = entered;
308
+ apiKey = entered;
309
+ }
310
+ }
283
311
  if (!apiKey) {
284
312
  const hint = provider.name === "nvidia"
285
313
  ? `Get one at https://build.nvidia.com, then one of:\n` +
@@ -687,5 +715,6 @@ async function main() {
687
715
  permissionRef.current = fn;
688
716
  }, onRequestElicitationReady: (fn) => {
689
717
  elicitationRef.current = fn;
690
- }, privacyMode: privacyMode }));
718
+ }, privacyMode: privacyMode, firstLaunch: firstLaunch }));
719
+ markBannerSeen(workspace);
691
720
  }
@@ -99,6 +99,25 @@ export function isRetryable(err) {
99
99
  }
100
100
  return false;
101
101
  }
102
+ /**
103
+ * A short, actionable translation for the non-retryable failures a
104
+ * first-time user is most likely to hit — a rejected key or an unknown
105
+ * model id — instead of the raw `{"status":403,"title":"Forbidden",...}`
106
+ * body the provider sent. Returns undefined for anything else, so callers
107
+ * fall back to the raw message unchanged.
108
+ */
109
+ export function friendlyProviderErrorHint(err, providerName) {
110
+ const status = err?.status;
111
+ if (status === 401 || status === 403) {
112
+ return (`Your API key for provider "${providerName}" was rejected. Check it's correct, ` +
113
+ `still active, and hasn't expired, then try again.`);
114
+ }
115
+ if (status === 404 && !isEmptyBodyNotFound(err)) {
116
+ return (`Provider "${providerName}" doesn't recognize this model. Check the model ID with ` +
117
+ `/model, or that your account has access to it.`);
118
+ }
119
+ return undefined;
120
+ }
102
121
  /** Read one header case-insensitively from whatever shape the SDK attached. */
103
122
  function headerValue(err, name) {
104
123
  const headers = err?.headers;
@@ -0,0 +1,36 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { CONFIG_DIR } from "../config/config.js";
4
+ import { hardenWindowsDir } from "../config/winAcl.js";
5
+ import { debugLog } from "../config/debug.js";
6
+ /**
7
+ * Tracks which workspaces have already shown the full ASCII banner once, so
8
+ * repeat launches (which cost real vertical space in a terminal) can fall
9
+ * back to a compact one-line header instead. Same shape and persistence
10
+ * pattern as aiDisclosure.ts's ai-disclosure.json.
11
+ */
12
+ const BANNER_FILE = path.join(CONFIG_DIR, "banner-seen.json");
13
+ function loadStore(storeFile) {
14
+ try {
15
+ const parsed = JSON.parse(fs.readFileSync(storeFile, "utf8"));
16
+ return parsed && typeof parsed === "object" ? parsed : {};
17
+ }
18
+ catch (err) {
19
+ // A missing file means "not shown anywhere yet" (normal); a malformed one
20
+ // means every workspace re-shows the full banner, worth being able to see.
21
+ debugLog(`loadStore(${storeFile})`, err);
22
+ return {};
23
+ }
24
+ }
25
+ /** Whether the full ASCII banner has already been shown for this workspace. */
26
+ export function isBannerSeen(workspace, storeFile = BANNER_FILE) {
27
+ return typeof loadStore(storeFile)[path.resolve(workspace)] === "string";
28
+ }
29
+ /** Record that the full ASCII banner has been shown for this workspace, now. */
30
+ export function markBannerSeen(workspace, storeFile = BANNER_FILE) {
31
+ const store = loadStore(storeFile);
32
+ store[path.resolve(workspace)] = new Date().toISOString();
33
+ fs.mkdirSync(path.dirname(storeFile), { recursive: true, mode: 0o700 });
34
+ hardenWindowsDir(path.dirname(storeFile));
35
+ fs.writeFileSync(storeFile, JSON.stringify(store, null, 2) + "\n", { mode: 0o600 });
36
+ }
@@ -0,0 +1,22 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { useState } from "react";
3
+ import { Box, Text, useInput } from "ink";
4
+ import TextInput from "ink-text-input";
5
+ /**
6
+ * Shown once, only when the active provider has no API key resolved, on the
7
+ * interactive TTY path (never from headless — see runHeadless in
8
+ * src/headless.ts, which never imports this component). Lets a first-time
9
+ * user paste a key instead of hunting through env-var/dotfile instructions.
10
+ */
11
+ export function ApiKeySetupPrompt({ providerName, envVarName, onDecision, }) {
12
+ const [value, setValue] = useState("");
13
+ useInput((_input, key) => {
14
+ if (key.escape)
15
+ onDecision({ action: "skip" });
16
+ });
17
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsxs(Text, { bold: true, color: "cyan", children: ["No API key found for provider \"", providerName, "\""] }), _jsxs(Text, { children: ["Paste your ", envVarName, " value below to save it to ~/.kritya/.env and continue \u2014 or press Esc to skip and set it up yourself."] }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "cyan", children: "> " }), _jsx(TextInput, { value: value, onChange: setValue, mask: "*", onSubmit: (v) => {
18
+ const trimmed = v.trim();
19
+ if (trimmed)
20
+ onDecision({ action: "save", key: trimmed });
21
+ } })] })] }));
22
+ }
package/dist/ui/App.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useCallback, useEffect, useRef, useState } from "react";
2
+ import { Fragment, useCallback, useEffect, useRef, useState } from "react";
3
3
  import { Box, Static, Text, useApp, useInput, useStdout } from "ink";
4
4
  import TextInput from "ink-text-input";
5
5
  import fs from "node:fs";
@@ -30,7 +30,7 @@ const IMAGE_RE = /\.(png|jpe?g|gif|webp|bmp)$/i;
30
30
  // Erase screen + scrollback, then home the cursor (same sequence Ink itself
31
31
  // uses for its own "content taller than the terminal" clear path).
32
32
  const CLEAR_TERMINAL = "\x1b[2J\x1b[3J\x1b[H";
33
- export function App({ agent, workspace, modelRef, providerRef, config, resumedCount, initialTasks, undoStack, uiBridge, resumeSessions, customCommands = [], mcpToolCount = 0, onSwitchClient, onRequestPermissionReady, onRequestElicitationReady, privacyMode, }) {
33
+ export function App({ agent, workspace, modelRef, providerRef, config, resumedCount, initialTasks, undoStack, uiBridge, resumeSessions, customCommands = [], mcpToolCount = 0, onSwitchClient, onRequestPermissionReady, onRequestElicitationReady, privacyMode, firstLaunch, }) {
34
34
  const { exit } = useApp();
35
35
  const { stdout } = useStdout();
36
36
  const [input, setInput] = useState("");
@@ -137,6 +137,7 @@ export function App({ agent, workspace, modelRef, providerRef, config, resumedCo
137
137
  resumeSessions,
138
138
  refreshFileList,
139
139
  onSwitchClient,
140
+ firstLaunch,
140
141
  });
141
142
  useEffect(() => {
142
143
  onRequestPermissionReady?.(requestPermission);
@@ -154,11 +155,49 @@ export function App({ agent, workspace, modelRef, providerRef, config, resumedCo
154
155
  const t = setInterval(() => setElapsed(Math.floor((Date.now() - start) / 1000)), 1000);
155
156
  return () => clearInterval(t);
156
157
  }, [phase]);
158
+ // Detail moved out of the always-visible status line (see StatusLine.tsx) —
159
+ // surfaced instead via the /status command, on demand.
160
+ const statusReport = useCallback(() => {
161
+ const sandboxActive = (config.sandboxExec ?? defaultSandboxMode()) !== "off" && sandboxAvailable();
162
+ const cachedPct = (totalUsage.cachedPromptTokens ?? 0) > 0
163
+ ? ` (${Math.round(((totalUsage.cachedPromptTokens ?? 0) / totalUsage.promptTokens) * 100)}% cached)`
164
+ : "";
165
+ const lines = [
166
+ `workspace: ${workspace}`,
167
+ `sandbox: ${sandboxActive ? "active" : "inactive"}`,
168
+ `context used: ${ctxPct}%`,
169
+ `token budget: ${budgetPct}% of ${tokenBudget.toLocaleString()} (${budgetUsed.toLocaleString()} used)${budgetStopped ? " — stopped" : ""}`,
170
+ `tokens: ${totalUsage.estimated ? "~" : ""}${totalUsage.promptTokens.toLocaleString()} in${cachedPct} / ${totalUsage.completionTokens.toLocaleString()} out`,
171
+ tasks.length > 0
172
+ ? `tasks: ${tasks.filter((t) => t.status === "done").length}/${tasks.length} done`
173
+ : null,
174
+ phase === "working" && elapsed > 0 ? `elapsed: ${elapsed}s` : null,
175
+ verbose ? "verbose: on" : null,
176
+ ].filter((l) => l !== null);
177
+ return lines.join("\n");
178
+ }, [
179
+ config,
180
+ workspace,
181
+ ctxPct,
182
+ budgetPct,
183
+ tokenBudget,
184
+ budgetUsed,
185
+ budgetStopped,
186
+ totalUsage,
187
+ tasks,
188
+ phase,
189
+ elapsed,
190
+ verbose,
191
+ ]);
157
192
  const allCommands = [
158
193
  ...BUILTIN_COMMANDS,
159
- ...customCommands.map((c) => ({ name: c.name, description: c.description })),
194
+ ...customCommands.map((c) => ({
195
+ name: c.name,
196
+ description: c.description,
197
+ category: "Custom",
198
+ })),
160
199
  // Servers can contribute slash commands too (MCP prompts).
161
- ...mcpPrompts().map((p) => ({ name: p.command, description: p.description })),
200
+ ...mcpPrompts().map((p) => ({ name: p.command, description: p.description, category: "MCP" })),
162
201
  ];
163
202
  // Command suggestions while typing a slash command (before any arguments).
164
203
  const suggestions = phase === "input" && input.startsWith("/") && !input.includes(" ")
@@ -308,6 +347,7 @@ export function App({ agent, workspace, modelRef, providerRef, config, resumedCo
308
347
  runWebSearch,
309
348
  expandMentions,
310
349
  costReport,
350
+ statusReport,
311
351
  gitDiffStat,
312
352
  exit,
313
353
  };
@@ -445,7 +485,18 @@ export function App({ agent, workspace, modelRef, providerRef, config, resumedCo
445
485
  setInput((prev) => (isCtrlEcho(prev, v) ? prev : v.replace(/\t/g, "")));
446
486
  setCmdIndex(0);
447
487
  setFileIndex(0);
448
- }, onSubmit: handleSubmit }, inputKey)] }), suggestions.length > 0 && (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [suggestions.map((c, i) => (_jsxs(Text, { color: i === selectedCmd ? "cyan" : undefined, children: [i === selectedCmd ? "❯ " : " ", _jsx(Text, { bold: i === selectedCmd, children: c.name }), _jsxs(Text, { dimColor: true, children: [" \u2014 ", c.description] })] }, c.name))), _jsx(Text, { dimColor: true, children: "\u2191\u2193 select \u00B7 Tab/Enter select \u00B7 Enter again to run" })] })), fileSuggestions.length > 0 && (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [fileSuggestions.map((f, i) => (_jsxs(Text, { color: i === selectedFile ? "cyan" : undefined, children: [i === selectedFile ? "❯ " : " ", f] }, f))), _jsx(Text, { dimColor: true, children: "\u2191\u2193 select \u00B7 Tab/Enter attach file" })] }))] })), _jsx(Box, { children: _jsx(StatusLine, { killed: killed, killReason: killReason, dryRunMode: dryRunMode, planMode: planMode, acceptEdits: acceptEdits, autoApprovedCount: autoApprovedCount, provider: provider, model: servedModel && servedModel !== model
488
+ }, onSubmit: handleSubmit }, inputKey)] }), suggestions.length > 0 && (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [(() => {
489
+ // Group headers only earn their keep once more than one
490
+ // category is actually on screen — once typing has filtered
491
+ // down to a single family, a lone header is just noise.
492
+ const showHeaders = new Set(suggestions.map((c) => c.category)).size > 1;
493
+ let lastCategory;
494
+ return suggestions.map((c, i) => {
495
+ const showHeader = showHeaders && c.category !== lastCategory;
496
+ lastCategory = c.category;
497
+ return (_jsxs(Fragment, { children: [showHeader && _jsx(Text, { dimColor: true, children: c.category ?? "Other" }), _jsxs(Text, { color: i === selectedCmd ? "cyan" : undefined, children: [i === selectedCmd ? "❯ " : " ", _jsx(Text, { bold: i === selectedCmd, children: c.name }), _jsxs(Text, { dimColor: true, children: [" \u2014 ", c.description] })] })] }, c.name));
498
+ });
499
+ })(), _jsx(Text, { dimColor: true, children: "\u2191\u2193 select \u00B7 Tab/Enter select \u00B7 Enter again to run" })] })), fileSuggestions.length > 0 && (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [fileSuggestions.map((f, i) => (_jsxs(Text, { color: i === selectedFile ? "cyan" : undefined, children: [i === selectedFile ? "❯ " : " ", f] }, f))), _jsx(Text, { dimColor: true, children: "\u2191\u2193 select \u00B7 Tab/Enter attach file" })] }))] })), _jsx(Box, { children: _jsx(StatusLine, { killed: killed, killReason: killReason, dryRunMode: dryRunMode, planMode: planMode, acceptEdits: acceptEdits, autoApprovedCount: autoApprovedCount, provider: provider, model: servedModel && servedModel !== model
449
500
  ? `${model}-${modelDisplaySlug(servedModel)}`
450
- : model, workflow: workflow, branch: branch, tasks: tasks, ctxPct: ctxPct, budgetPct: budgetPct, budgetStopped: budgetStopped, phase: phase, elapsed: elapsed, totalUsage: totalUsage, totalCost: totalCost, verbose: verbose, workspace: workspace, sandboxActive: (config.sandboxExec ?? defaultSandboxMode()) !== "off" && sandboxAvailable(), persistenceWarningCount: persistenceWarningCount, privacyMode: privacyMode }) })] }));
501
+ : model, workflow: workflow, branch: branch, budgetPct: budgetPct, budgetStopped: budgetStopped, totalCost: totalCost, sandboxActive: (config.sandboxExec ?? defaultSandboxMode()) !== "off" && sandboxAvailable(), persistenceWarningCount: persistenceWarningCount, privacyMode: privacyMode }) })] }));
451
502
  }
package/dist/ui/Banner.js CHANGED
@@ -50,7 +50,10 @@ function rowColor(row) {
50
50
  return `#${[r, g, b].map((c) => c.toString(16).padStart(2, "0")).join("")}`;
51
51
  }
52
52
  const BETA_LABEL = "beta";
53
- export function Banner({ subtitle }) {
53
+ export function Banner({ subtitle, compact }) {
54
+ if (compact) {
55
+ return (_jsx(Box, { marginBottom: 1, children: _jsx(Text, { dimColor: true, children: subtitle }) }));
56
+ }
54
57
  const columns = terminalColumns(process.stdout);
55
58
  const wide = bannerLines("KRITYA", "░░");
56
59
  const narrow = bannerLines("KRITYA", "░");
@@ -1,16 +1,19 @@
1
1
  import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Text } from "ink";
3
- /** The single dim status bar pinned to the bottom of the screen. */
4
- export function StatusLine({ killed, killReason, dryRunMode, planMode, acceptEdits, autoApprovedCount, provider, model, workflow, branch, tasks, ctxPct, budgetPct, budgetStopped, phase, elapsed, totalUsage, totalCost, verbose, workspace, sandboxActive, persistenceWarningCount, privacyMode, }) {
3
+ import { displayModelId } from "../config/models.js";
4
+ /**
5
+ * The single dim status bar pinned to the bottom of the screen. Keeps only
6
+ * the fields worth glancing at on every render; everything else (context %,
7
+ * task checklist, elapsed time, token counts, workspace path, verbose flag)
8
+ * moved to the on-demand `/status` command — see CommandContext.statusReport
9
+ * in src/commands/registry.ts.
10
+ */
11
+ export function StatusLine({ killed, killReason, dryRunMode, planMode, acceptEdits, autoApprovedCount, provider, model, workflow, branch, budgetPct, budgetStopped, totalCost, sandboxActive, persistenceWarningCount, privacyMode, }) {
5
12
  return (_jsxs(Text, { dimColor: true, children: [killed ? (_jsxs(Text, { bold: true, color: "red", children: ["\u26D4 KILLED", killReason ? ` (${killReason})` : "", " \u00B7", " "] })) : (""), _jsxs(Text, { color: dryRunMode || planMode ? "cyan" : acceptEdits ? "green" : undefined, children: ["mode:", " ", dryRunMode
6
13
  ? "dry-run"
7
14
  : planMode
8
15
  ? "plan"
9
16
  : acceptEdits
10
17
  ? `accept-edits (${autoApprovedCount} auto)`
11
- : "default", " · "] }), provider, "/", model, _jsxs(Text, { color: sandboxActive ? "green" : "red", children: [" ", "\u00B7 ", sandboxActive ? "🔒 sandbox:active" : "🔓 sandbox:inactive"] }), workflow ? (_jsxs(Text, { color: "magenta", children: [" ", "\u00B7 \u2691 ", workflow.name, ":", workflow.phase] })) : (""), branch ? ` · ⎇ ${branch}` : "", tasks.length > 0
12
- ? ` · tasks ${tasks.filter((t) => t.status === "done").length}/${tasks.length}`
13
- : "", ctxPct > 0 ? ` · ctx ${ctxPct}%` : "", budgetPct > 0 ? (_jsxs(Text, { color: budgetStopped ? "red" : budgetPct >= 80 ? "yellow" : undefined, children: [" ", "\u00B7 budget ", budgetPct, "%"] })) : (""), phase === "working" && elapsed > 0 ? ` · ${elapsed}s` : "", " \u00B7", " ", totalUsage.estimated ? "~" : "", "tokens: ", totalUsage.promptTokens.toLocaleString(), " in", (totalUsage.cachedPromptTokens ?? 0) > 0
14
- ? ` (${Math.round(((totalUsage.cachedPromptTokens ?? 0) / totalUsage.promptTokens) * 100)}% cached)`
15
- : "", " ", "/ ", totalUsage.completionTokens.toLocaleString(), " out", totalCost > 0 ? ` · $${totalCost.toFixed(4)}` : "", persistenceWarningCount > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 \u26A0 persistence warnings: ", persistenceWarningCount] })) : (""), privacyMode ? _jsx(Text, { color: "cyan", children: " \u00B7 privacy:on" }) : "", verbose ? " · verbose" : "", " \u00B7 workspace: ", workspace] }));
18
+ : "default", " · "] }), displayModelId(provider, model), _jsxs(Text, { color: sandboxActive ? "green" : "red", children: [" ", "\u00B7 ", sandboxActive ? "🔒 sandbox:active" : "🔓 sandbox:inactive"] }), workflow ? (_jsxs(Text, { color: "magenta", children: [" ", "\u00B7 \u2691 ", workflow.name, ":", workflow.phase] })) : (""), branch ? ` · ⎇ ${branch}` : "", budgetStopped || budgetPct >= 80 ? (_jsxs(Text, { color: budgetStopped ? "red" : "yellow", children: [" \u00B7 budget ", budgetPct, "%"] })) : (""), totalCost > 0 ? ` · $${totalCost.toFixed(4)}` : "", persistenceWarningCount > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 \u26A0 persistence warnings: ", persistenceWarningCount] })) : (""), privacyMode ? _jsx(Text, { color: "cyan", children: " \u00B7 privacy:on" }) : "", " · /status for details"] }));
16
19
  }
@@ -9,5 +9,5 @@ export function TranscriptItem({ item, verbose, stdout }) {
9
9
  return (_jsxs(Box, { marginBottom: item.kind === "tool" ? 0 : 1, flexDirection: "column", children: [item.kind === "user" && (_jsxs(Text, { children: [_jsxs(Text, { bold: true, color: "green", children: ["\u276F", " "] }), item.text] })), item.kind === "assistant" && _jsx(Markdown, { text: item.text }), item.kind === "tool" && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: [item.error ? _jsx(Text, { color: "red", children: "\u2717" }) : _jsx(Text, { color: "green", children: "\u2713" }), " ", item.summary, item.resultSummary && !verbose ? ` — ${item.resultSummary}` : ""] }), item.output && item.output.trim() && (item.resultSummary === undefined || verbose) && (_jsx(Text, { dimColor: true, children: toolOutputPreview(item.output, verbose, Math.max(20, terminalColumns(stdout) - 6), item.error)
10
10
  .split("\n")
11
11
  .map((l) => ` ${l}`)
12
- .join("\n") }))] })), item.kind === "info" && _jsx(Text, { dimColor: true, children: item.text }), item.kind === "banner" && _jsx(Banner, { subtitle: item.subtitle })] }));
12
+ .join("\n") }))] })), item.kind === "info" && _jsx(Text, { dimColor: true, children: item.text }), item.kind === "banner" && _jsx(Banner, { subtitle: item.subtitle, compact: item.compact })] }));
13
13
  }
@@ -2,8 +2,8 @@ import { useCallback, useEffect, useRef, useState } from "react";
2
2
  import path from "node:path";
3
3
  import { gitBranch } from "../git/git.js";
4
4
  import { listProviders, resolveProvider, saveConfig, saveProviderModel, } from "../config/config.js";
5
- import { DEFAULT_MODEL, contextWindowFor } from "../config/models.js";
6
- import { ProviderClient, RetryExhaustedError } from "../provider/client.js";
5
+ import { DEFAULT_MODEL, contextWindowFor, displayModelId } from "../config/models.js";
6
+ import { ProviderClient, RetryExhaustedError, friendlyProviderErrorHint, } from "../provider/client.js";
7
7
  import { createSwitchyardClient } from "../provider/switchyardClient.js";
8
8
  import { SWITCHYARD_ROUTE_ID, resolveEffectiveModel } from "../provider/switchyardSidecar.js";
9
9
  import { KillSwitchError } from "../agent/killSwitch.js";
@@ -29,7 +29,7 @@ function initialToolStatus(name) {
29
29
  * permission state, usage/cost tracking, and session resume. Input handling
30
30
  * (text box, slash-command suggestions, @mention autocomplete) stays in App.
31
31
  */
32
- export function useAgent({ agent, workspace, modelRef, providerRef, config, uiBridge, resumedCount, initialTasks, resumeSessions, refreshFileList, onSwitchClient, }) {
32
+ export function useAgent({ agent, workspace, modelRef, providerRef, config, uiBridge, resumedCount, initialTasks, resumeSessions, refreshFileList, onSwitchClient, firstLaunch, }) {
33
33
  const nextId = useRef(0);
34
34
  const [items, setItems] = useState(() => {
35
35
  const initial = [
@@ -37,6 +37,7 @@ export function useAgent({ agent, workspace, modelRef, providerRef, config, uiBr
37
37
  id: nextId.current++,
38
38
  kind: "banner",
39
39
  subtitle: `${path.basename(workspace)} · ${modelRef.current} · type a request, or /help for commands`,
40
+ compact: !firstLaunch,
40
41
  },
41
42
  ];
42
43
  if (resumedCount > 0) {
@@ -382,7 +383,7 @@ export function useAgent({ agent, workspace, modelRef, providerRef, config, uiBr
382
383
  return;
383
384
  }
384
385
  setPhase("working");
385
- setActivity(`Calling ${provider}/${model}…`);
386
+ setActivity(`Calling ${displayModelId(provider, model)}…`);
386
387
  const ac = new AbortController();
387
388
  abortRef.current = ac;
388
389
  setInFlight([]);
@@ -468,7 +469,8 @@ export function useAgent({ agent, workspace, modelRef, providerRef, config, uiBr
468
469
  text = `Error: ${message}${hint}`;
469
470
  }
470
471
  else {
471
- text = `Error: ${message}`;
472
+ const friendlyHint = friendlyProviderErrorHint(err, provider);
473
+ text = friendlyHint ? `${friendlyHint}\n\n(${message})` : `Error: ${message}`;
472
474
  }
473
475
  }
474
476
  addItem({ kind: "info", text });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kritya",
3
- "version": "0.8.24-beta",
3
+ "version": "0.8.25-beta",
4
4
  "description": "Kritya — a lean, provider-agnostic terminal coding agent (NVIDIA, OpenAI, OpenRouter, Groq, Ollama, and more)",
5
5
  "type": "module",
6
6
  "publishConfig": {