amness 0.1.2 → 0.1.3
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 +2 -2
- package/dist/cli/amness.js +3 -3
- package/dist/cli/config.d.ts +22 -1
- package/dist/cli/config.js +102 -40
- package/dist/cli/models.d.ts +8 -0
- package/dist/cli/models.js +31 -0
- package/dist/cli/tui/app.d.ts +3 -1
- package/dist/cli/tui/app.js +34 -7
- package/dist/cli/tui/argcomplete.d.ts +12 -0
- package/dist/cli/tui/argcomplete.js +55 -0
- package/dist/cli/tui/cards.d.ts +2 -0
- package/dist/cli/tui/cards.js +4 -3
- package/dist/cli/tui/commands.js +93 -24
- package/dist/cli/tui/complete.d.ts +23 -3
- package/dist/cli/tui/complete.js +38 -3
- package/dist/cli/tui/full.js +18 -8
- package/docs/providers.md +13 -1
- package/docs/safety.md +3 -3
- package/docs/usage.md +27 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -28,13 +28,13 @@ Give Amness the key once, in whichever way suits you:
|
|
|
28
28
|
|
|
29
29
|
| How | Command or step | Saved? |
|
|
30
30
|
|---|---|---|
|
|
31
|
-
| **Paste it on first start** | run `amness`, choose a provider, paste the key (hidden).
|
|
31
|
+
| **Paste it on first start** | run `amness`, choose a provider, paste the key (hidden). Add more providers any time with `/connect`, and switch with `/provider`. | Yes, in your system's secure store: the macOS Keychain, the Linux keyring, or Windows DPAPI. The prompt tells you which. |
|
|
32
32
|
| **Environment variable** | `export ANTHROPIC_API_KEY=sk-ant-...` or `export OPENAI_API_KEY=sk-...` (PowerShell: `$env:OPENAI_API_KEY="sk-..."`) | No. Used for that shell only, and never written to disk. It wins over a saved key. |
|
|
33
33
|
| **Secret file** (Docker, Kubernetes) | `ANTHROPIC_API_KEY_FILE=/run/secrets/anthropic` (also `OPENAI_API_KEY_FILE`, `TAVILY_API_KEY_FILE`) | No. Read from the file each run. |
|
|
34
34
|
| **Docker** | `docker run -it --rm -v "$PWD":/work -e ANTHROPIC_API_KEY amness` | No. Inside a container nothing is saved. |
|
|
35
35
|
| **Library or CI** | pass `apiKey` in code, or set the variable as a CI secret | No. |
|
|
36
36
|
|
|
37
|
-
`OPENAI_API_KEY` is used for every provider except Anthropic
|
|
37
|
+
Each provider keeps its own saved key, so you can connect several and switch with `/provider`; `/connect` shows which are connected. `OPENAI_API_KEY` is used for every provider except Anthropic (OpenRouter prefers `OPENROUTER_API_KEY`). To remove a saved key, clear "API key" in the settings. A Linux server with no keyring falls back to `~/.amness/config.json` (mode 600, **not encrypted**), so use an environment variable or your platform's secret manager there. Details: [Safety](docs/safety.md).
|
|
38
38
|
|
|
39
39
|
More ways to start:
|
|
40
40
|
|
package/dist/cli/amness.js
CHANGED
|
@@ -15,7 +15,7 @@ import { runPlugin } from "./plugincli.js";
|
|
|
15
15
|
import { runSchedule } from "./schedulecli.js";
|
|
16
16
|
import { ACP_HELP, runAcp } from "./acpcli.js";
|
|
17
17
|
import { runEval, runTune } from "./evalcli.js";
|
|
18
|
-
import { CLAUDE_MODELS, OPENAI_MODELS, PROVIDERS, home, loadConfig, mask, ready, saveConfig } from "./config.js";
|
|
18
|
+
import { CLAUDE_MODELS, OPENAI_MODELS, PROVIDERS, home, keyFor, loadConfig, mask, ready, saveConfig } from "./config.js";
|
|
19
19
|
import { where } from "./keystore.js";
|
|
20
20
|
import { DEMO_TASK, explain, runDemo } from "./demo.js";
|
|
21
21
|
import { formatEvent } from "./format.js";
|
|
@@ -91,7 +91,7 @@ async function ensureReady() {
|
|
|
91
91
|
cfg.model = (await ask("Model", cfg.model)) || cfg.model;
|
|
92
92
|
}
|
|
93
93
|
if (PROVIDERS[id].needsKey)
|
|
94
|
-
cfg.apiKey = (await ask(`API key (hidden; ${where()})`, "", { secret: true })) || undefined;
|
|
94
|
+
cfg.apiKey = keyFor(id).key ?? ((await ask(`API key (hidden; ${where()})`, "", { secret: true })) || undefined);
|
|
95
95
|
saveConfig(cfg);
|
|
96
96
|
if (!ready(cfg))
|
|
97
97
|
return false;
|
|
@@ -432,7 +432,7 @@ async function settings() {
|
|
|
432
432
|
if (what === "provider") {
|
|
433
433
|
const id = await select("Provider", Object.keys(PROVIDERS).map((k) => ({ label: PROVIDERS[k].label, value: k, hint: PROVIDERS[k].baseUrl })));
|
|
434
434
|
if (id)
|
|
435
|
-
Object.assign(cfg, { provider: id, baseUrl: PROVIDERS[id].baseUrl, model: PROVIDERS[id].model });
|
|
435
|
+
Object.assign(cfg, { provider: id, baseUrl: PROVIDERS[id].baseUrl, model: PROVIDERS[id].model, apiKey: keyFor(id).key });
|
|
436
436
|
}
|
|
437
437
|
else if (what === "model") {
|
|
438
438
|
if (cfg.provider === "anthropic" || cfg.provider === "openai") {
|
package/dist/cli/config.d.ts
CHANGED
|
@@ -35,7 +35,8 @@ export type Config = {
|
|
|
35
35
|
provider: ProviderId;
|
|
36
36
|
baseUrl: string;
|
|
37
37
|
model: string;
|
|
38
|
-
apiKey?: string;
|
|
38
|
+
apiKey?: string; /** saved keys for the other providers, only where there is no OS store */
|
|
39
|
+
apiKeys?: Partial<Record<ProviderId, string>>;
|
|
39
40
|
tavilyKey?: string;
|
|
40
41
|
maxTokens: number;
|
|
41
42
|
effort?: "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max"; /** OpenAI wire API: auto (default) picks Responses for reasoning models and self-corrects */
|
|
@@ -65,6 +66,26 @@ export type Config = {
|
|
|
65
66
|
};
|
|
66
67
|
export declare const home: () => string;
|
|
67
68
|
export declare const defaults: () => Config;
|
|
69
|
+
/** The variable that holds a provider's key. Anthropic has its own; OpenRouter may have its own; every other provider uses OPENAI_API_KEY. */
|
|
70
|
+
export declare const envNameFor: (p: ProviderId) => "ANTHROPIC_API_KEY" | "OPENROUTER_API_KEY" | "OPENAI_API_KEY";
|
|
71
|
+
export declare const envKeyFor: (p: ProviderId) => string | undefined;
|
|
72
|
+
export type KeyState = {
|
|
73
|
+
key?: string;
|
|
74
|
+
source: "env" | "saved" | "none";
|
|
75
|
+
};
|
|
76
|
+
/** The key to use for a provider: an environment variable wins over a saved key. */
|
|
77
|
+
export declare function keyFor(p: ProviderId): KeyState;
|
|
78
|
+
/** Save (or, with no key, remove) a provider's key. Uses the OS store; else config.json (mode 600); in a container, memory only. */
|
|
79
|
+
export declare function storeKey(p: ProviderId, key?: string): void;
|
|
80
|
+
export type Connection = {
|
|
81
|
+
id: ProviderId;
|
|
82
|
+
label: string;
|
|
83
|
+
needsKey: boolean;
|
|
84
|
+
source: KeyState["source"];
|
|
85
|
+
env: string;
|
|
86
|
+
};
|
|
87
|
+
/** Every provider and whether it is ready to use. */
|
|
88
|
+
export declare function connections(): Connection[];
|
|
68
89
|
export declare function loadConfig(): Config;
|
|
69
90
|
export declare function saveConfig(c: Config): void;
|
|
70
91
|
export declare const ready: (c: Config) => boolean;
|
package/dist/cli/config.js
CHANGED
|
@@ -12,7 +12,11 @@ export const PROVIDERS = {
|
|
|
12
12
|
export const home = () => process.env.AMNESS_HOME ?? join(homedir(), ".amness");
|
|
13
13
|
const file = () => join(home(), "config.json");
|
|
14
14
|
export const defaults = () => ({ provider: "openai", baseUrl: PROVIDERS.openai.baseUrl, model: PROVIDERS.openai.model, maxTokens: 300_000 });
|
|
15
|
-
|
|
15
|
+
// ---- keys. One saved key per provider (account "apiKey-<provider>" in the OS store), so you can be connected to several at once. `cfg.apiKey` is always the active provider's key.
|
|
16
|
+
const acct = (p) => `apiKey-${p}`;
|
|
17
|
+
let fileKeys = {}, fileKeysLoaded = false; // only where there is no OS store (a Linux server): kept in config.json, mode 600
|
|
18
|
+
const sessionKeys = {}; // in a container: this run only
|
|
19
|
+
const persisted = {}; // what is stored now, so a save does not rewrite an unchanged key
|
|
16
20
|
/** An environment variable, or the file NAME_FILE points to (Docker and Kubernetes secrets are mounted as files, for example /run/secrets/openai). */
|
|
17
21
|
const envSecret = (name) => {
|
|
18
22
|
const v = process.env[name];
|
|
@@ -28,7 +32,58 @@ const envSecret = (name) => {
|
|
|
28
32
|
return undefined;
|
|
29
33
|
}
|
|
30
34
|
};
|
|
31
|
-
|
|
35
|
+
/** The variable that holds a provider's key. Anthropic has its own; OpenRouter may have its own; every other provider uses OPENAI_API_KEY. */
|
|
36
|
+
export const envNameFor = (p) => (p === "anthropic" ? "ANTHROPIC_API_KEY" : p === "openrouter" && envSecret("OPENROUTER_API_KEY") ? "OPENROUTER_API_KEY" : "OPENAI_API_KEY");
|
|
37
|
+
export const envKeyFor = (p) => envSecret(envNameFor(p));
|
|
38
|
+
const loadFileKeys = () => { if (!fileKeysLoaded) {
|
|
39
|
+
fileKeysLoaded = true;
|
|
40
|
+
fileKeys = { ...readFileCfg().apiKeys };
|
|
41
|
+
} };
|
|
42
|
+
const savedKey = (p) => { loadFileKeys(); return getSecret(acct(p)) ?? fileKeys[p] ?? sessionKeys[p]; };
|
|
43
|
+
/** The key to use for a provider: an environment variable wins over a saved key. */
|
|
44
|
+
export function keyFor(p) {
|
|
45
|
+
const e = envKeyFor(p);
|
|
46
|
+
if (e)
|
|
47
|
+
return { key: e, source: "env" };
|
|
48
|
+
const s = savedKey(p);
|
|
49
|
+
if (s) {
|
|
50
|
+
persisted[p] = s;
|
|
51
|
+
return { key: s, source: "saved" };
|
|
52
|
+
}
|
|
53
|
+
return { source: "none" };
|
|
54
|
+
}
|
|
55
|
+
/** Save (or, with no key, remove) a provider's key. Uses the OS store; else config.json (mode 600); in a container, memory only. */
|
|
56
|
+
export function storeKey(p, key) {
|
|
57
|
+
loadFileKeys();
|
|
58
|
+
persisted[p] = key;
|
|
59
|
+
if (!key) {
|
|
60
|
+
deleteSecret(acct(p));
|
|
61
|
+
delete fileKeys[p];
|
|
62
|
+
delete sessionKeys[p];
|
|
63
|
+
writeConfigFile(readFileCfg());
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (setSecret(acct(p), key)) {
|
|
67
|
+
delete fileKeys[p];
|
|
68
|
+
delete sessionKeys[p];
|
|
69
|
+
}
|
|
70
|
+
else if (noStore())
|
|
71
|
+
sessionKeys[p] = key;
|
|
72
|
+
else
|
|
73
|
+
fileKeys[p] = key;
|
|
74
|
+
writeConfigFile(readFileCfg());
|
|
75
|
+
}
|
|
76
|
+
/** Every provider and whether it is ready to use. */
|
|
77
|
+
export function connections() {
|
|
78
|
+
return Object.keys(PROVIDERS).map((id) => ({ id, label: PROVIDERS[id].label, needsKey: PROVIDERS[id].needsKey, source: PROVIDERS[id].needsKey ? keyFor(id).source : "saved", env: envNameFor(id) }));
|
|
79
|
+
}
|
|
80
|
+
const readFileCfg = () => { try {
|
|
81
|
+
return { ...defaults(), ...JSON.parse(readFileSync(file(), "utf8")) };
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return defaults();
|
|
85
|
+
} };
|
|
86
|
+
let tavilyEnv;
|
|
32
87
|
export function loadConfig() {
|
|
33
88
|
let c = defaults(), onDisk = {};
|
|
34
89
|
try {
|
|
@@ -37,33 +92,40 @@ export function loadConfig() {
|
|
|
37
92
|
c = { ...c, ...onDisk };
|
|
38
93
|
}
|
|
39
94
|
catch { /* corrupt config: start fresh */ }
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
95
|
+
fileKeys = { ...onDisk.apiKeys };
|
|
96
|
+
fileKeysLoaded = true;
|
|
97
|
+
delete c.apiKeys;
|
|
98
|
+
let dirty = false;
|
|
99
|
+
// older versions kept ONE key (account "apiKey", or a field in the file) for whichever provider was active: give it to that provider
|
|
100
|
+
const legacy = [getSecret("apiKey"), onDisk.apiKey].find(Boolean);
|
|
101
|
+
if (legacy && !savedKey(c.provider)) {
|
|
102
|
+
fileKeys[c.provider] = legacy;
|
|
103
|
+
dirty = true;
|
|
104
|
+
}
|
|
105
|
+
if (getSecret("apiKey"))
|
|
106
|
+
deleteSecret("apiKey");
|
|
107
|
+
if (onDisk.apiKey) {
|
|
108
|
+
delete onDisk.apiKey;
|
|
109
|
+
dirty = true;
|
|
110
|
+
}
|
|
111
|
+
// keys still in the file move to the OS store, and leave the file only after they read back
|
|
112
|
+
for (const p of Object.keys(fileKeys))
|
|
113
|
+
if (fileKeys[p] && setSecret(acct(p), fileKeys[p])) {
|
|
114
|
+
delete fileKeys[p];
|
|
115
|
+
dirty = true;
|
|
54
116
|
}
|
|
117
|
+
const tv = getSecret("tavilyKey") ?? onDisk.tavilyKey;
|
|
118
|
+
if (onDisk.tavilyKey && setSecret("tavilyKey", onDisk.tavilyKey)) {
|
|
119
|
+
delete onDisk.tavilyKey;
|
|
120
|
+
dirty = true;
|
|
55
121
|
}
|
|
56
|
-
if (
|
|
57
|
-
writeConfigFile({ ...defaults(), ...onDisk,
|
|
58
|
-
// environment wins, so CI and one-off runs work without touching the saved key. It is never written back (see saveConfig).
|
|
122
|
+
if (dirty)
|
|
123
|
+
writeConfigFile({ ...defaults(), ...onDisk, apiKey: undefined, tavilyKey: onDisk.tavilyKey });
|
|
59
124
|
const claude = c.provider === "anthropic";
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const tavily = envSecret("TAVILY_API_KEY");
|
|
65
|
-
if (tavily)
|
|
66
|
-
c.tavilyKey = fromEnv.tavilyKey = tavily;
|
|
125
|
+
c.apiKey = keyFor(c.provider).key;
|
|
126
|
+
// environment wins, so CI and one-off runs work without touching the saved key. It is never written back (see saveConfig).
|
|
127
|
+
tavilyEnv = envSecret("TAVILY_API_KEY");
|
|
128
|
+
c.tavilyKey = tavilyEnv ?? tv;
|
|
67
129
|
if (process.env.OPENAI_BASE_URL && !claude)
|
|
68
130
|
c.baseUrl = process.env.OPENAI_BASE_URL;
|
|
69
131
|
if (process.env.AMNESS_MODEL)
|
|
@@ -72,25 +134,25 @@ export function loadConfig() {
|
|
|
72
134
|
}
|
|
73
135
|
function writeConfigFile(c) {
|
|
74
136
|
mkdirSync(home(), { recursive: true });
|
|
75
|
-
|
|
137
|
+
const out = { ...c, apiKeys: Object.keys(fileKeys).length ? fileKeys : undefined };
|
|
138
|
+
writeFileSync(file(), JSON.stringify(out, null, 2), { mode: 0o600 }); // owner-only from the first byte
|
|
76
139
|
chmodSync(file(), 0o600); // and for a file that already existed
|
|
77
140
|
}
|
|
78
141
|
export function saveConfig(c) {
|
|
79
142
|
const out = { ...c };
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
}
|
|
91
|
-
if (setSecret(k, v) || noStore())
|
|
92
|
-
delete out[k]; // stored in the OS credential store; otherwise it stays in the file (mode 600), except in a container, where it is kept in memory only
|
|
143
|
+
delete out.apiKey;
|
|
144
|
+
delete out.apiKeys;
|
|
145
|
+
if (c.apiKey !== persisted[c.provider] && !(c.apiKey && c.apiKey === envKeyFor(c.provider)))
|
|
146
|
+
storeKey(c.provider, c.apiKey); // a key from an environment variable is never stored
|
|
147
|
+
const tv = c.tavilyKey;
|
|
148
|
+
if (tv && tv === tavilyEnv)
|
|
149
|
+
delete out.tavilyKey;
|
|
150
|
+
else if (!tv) {
|
|
151
|
+
deleteSecret("tavilyKey");
|
|
152
|
+
delete out.tavilyKey;
|
|
93
153
|
}
|
|
154
|
+
else if (setSecret("tavilyKey", tv) || noStore())
|
|
155
|
+
delete out.tavilyKey;
|
|
94
156
|
writeConfigFile(out);
|
|
95
157
|
}
|
|
96
158
|
export const ready = (c) => !PROVIDERS[c.provider].needsKey || !!c.apiKey;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
const cache = new Map();
|
|
2
|
+
const TTL = 10 * 60_000;
|
|
3
|
+
const NOT_CHAT = /embed|whisper|tts|dall-e|moderation|davinci|babbage|transcribe|realtime|audio|image|rerank/i;
|
|
4
|
+
export async function fetchModels(p, fetchImpl = fetch) {
|
|
5
|
+
if (!p.apiKey && p.provider !== "ollama" && p.provider !== "custom")
|
|
6
|
+
return []; // a hosted provider refuses a request with no key: do not even try
|
|
7
|
+
const key = `${p.provider}|${p.baseUrl}|${(p.apiKey ?? "").slice(-6)}`, hit = cache.get(key);
|
|
8
|
+
if (hit && Date.now() - hit.at < TTL)
|
|
9
|
+
return hit.ids;
|
|
10
|
+
const base = p.baseUrl.replace(/\/+$/, "");
|
|
11
|
+
const anthropic = p.provider === "anthropic";
|
|
12
|
+
try {
|
|
13
|
+
const res = await fetchImpl(anthropic ? `${base}/v1/models?limit=100` : `${base}/models`, {
|
|
14
|
+
headers: anthropic ? { "x-api-key": p.apiKey ?? "", "anthropic-version": "2023-06-01" } : p.apiKey ? { authorization: `Bearer ${p.apiKey}` } : {},
|
|
15
|
+
signal: AbortSignal.timeout(6000),
|
|
16
|
+
});
|
|
17
|
+
if (!res.ok)
|
|
18
|
+
return [];
|
|
19
|
+
const body = (await res.json());
|
|
20
|
+
const ids = [...(body.data ?? []).map((m) => m.id), ...(body.models ?? []).map((m) => m.model ?? m.name)] // Ollama's native shape uses `models`
|
|
21
|
+
.filter((x) => typeof x === "string" && !!x && !NOT_CHAT.test(x));
|
|
22
|
+
const uniq = [...new Set(ids)].sort();
|
|
23
|
+
cache.set(key, { at: Date.now(), ids: uniq });
|
|
24
|
+
return uniq;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** For tests. */
|
|
31
|
+
export const clearModelCache = () => cache.clear();
|
package/dist/cli/tui/app.d.ts
CHANGED
|
@@ -131,9 +131,11 @@ export declare class App {
|
|
|
131
131
|
items: Suggestion[];
|
|
132
132
|
sel: number;
|
|
133
133
|
token: Token;
|
|
134
|
-
kind: Token["kind"];
|
|
134
|
+
kind: Token["kind"]; /** the user moved into the list with the arrow keys */
|
|
135
|
+
nav?: boolean;
|
|
135
136
|
} | null;
|
|
136
137
|
suggOff: string;
|
|
138
|
+
private argPending;
|
|
137
139
|
private builtin;
|
|
138
140
|
private customAt;
|
|
139
141
|
private customCmds;
|
package/dist/cli/tui/app.js
CHANGED
|
@@ -15,7 +15,8 @@ import { BlockStore } from "./blocks.js";
|
|
|
15
15
|
import { buildCommands } from "./commands.js";
|
|
16
16
|
import { FullView } from "./full.js";
|
|
17
17
|
import { loadCustomCommands } from "./custom.js";
|
|
18
|
-
import {
|
|
18
|
+
import { argOptions } from "./argcomplete.js";
|
|
19
|
+
import { FileIndex, fuzzyScore, applySuggestion, menuRows, rankCommands, rankFiles, tokenAtCursor } from "./complete.js";
|
|
19
20
|
import { Editor } from "./editor.js";
|
|
20
21
|
import { KeyParser, PASTE_OFF, PASTE_ON } from "./keys.js";
|
|
21
22
|
import { renderMarkdown, takeCompleteBlocks } from "./markdown.js";
|
|
@@ -80,6 +81,7 @@ export class App {
|
|
|
80
81
|
pager = null;
|
|
81
82
|
sugg = null;
|
|
82
83
|
suggOff = "";
|
|
84
|
+
argPending = new Set();
|
|
83
85
|
builtin;
|
|
84
86
|
customAt = 0;
|
|
85
87
|
customCmds = [];
|
|
@@ -370,11 +372,16 @@ export class App {
|
|
|
370
372
|
setTitle(t) { if (this.io.output.isTTY !== false)
|
|
371
373
|
this.io.output.write(`\x1b]0;${t}\x07`); }
|
|
372
374
|
suggLines(w) {
|
|
373
|
-
const s = this.sugg,
|
|
374
|
-
|
|
375
|
-
|
|
375
|
+
const s = this.sugg, { rows, above, below } = menuRows(s.items, s.sel, Math.min(12, Math.max(4, this.screen.rows - 8)));
|
|
376
|
+
const lines = rows.map((r) => {
|
|
377
|
+
if ("head" in r)
|
|
378
|
+
return dim(` ── ${r.head} ${"─".repeat(Math.max(2, 20 - r.head.length))}`);
|
|
379
|
+
const it = s.items[r.item], on = r.item === s.sel, label = s.kind === "file" ? it.label : it.label.padEnd(16);
|
|
376
380
|
return (on ? cyan("❯ ") + bold(cyan(label)) : " " + label) + (it.detail ? " " + (on ? gray(it.detail) : dim(it.detail)) : "");
|
|
377
381
|
}).map((l) => (cellWidth(l) > w ? l.slice(0, w + 12) : l));
|
|
382
|
+
if (above || below)
|
|
383
|
+
lines.push(dim(` ${s.sel + 1}/${s.items.length} ${above ? "↑" : " "}${below ? "↓" : " "} ↑↓ move · PgUp/PgDn page`));
|
|
384
|
+
return lines;
|
|
378
385
|
}
|
|
379
386
|
tick = () => { this.frame++; this.render(); };
|
|
380
387
|
ensureTimer() { if (!this.timer)
|
|
@@ -451,7 +458,7 @@ export class App {
|
|
|
451
458
|
if (k.name === "enter" && !k.alt && !k.shift) {
|
|
452
459
|
if (this.sugg && this.sugg.items.length) {
|
|
453
460
|
const it = this.sugg.items[this.sugg.sel];
|
|
454
|
-
if (!(this.sugg.kind === "slash" && ed.text.trim() === it.label))
|
|
461
|
+
if (!(this.sugg.kind === "slash" && ed.text.trim() === it.label) && !(this.sugg.kind === "arg" && (this.sugg.items.some((x) => x.label === this.sugg.token.query) || (it.open && !this.sugg.nav))))
|
|
455
462
|
return this.acceptSugg(true);
|
|
456
463
|
}
|
|
457
464
|
if (ed.text.slice(0, ed.cursorIndex).endsWith("\\")) {
|
|
@@ -461,10 +468,16 @@ export class App {
|
|
|
461
468
|
}
|
|
462
469
|
return void this.submit();
|
|
463
470
|
}
|
|
471
|
+
if (this.sugg && (k.name === "pageup" || k.name === "pagedown")) {
|
|
472
|
+
const n = this.sugg.items.length;
|
|
473
|
+
this.sugg.sel = Math.max(0, Math.min(n - 1, this.sugg.sel + (k.name === "pageup" ? -8 : 8)));
|
|
474
|
+
return this.render();
|
|
475
|
+
}
|
|
464
476
|
if (k.name === "up" || k.name === "down") {
|
|
465
477
|
if (this.sugg) {
|
|
466
478
|
const n = this.sugg.items.length;
|
|
467
479
|
this.sugg.sel = (this.sugg.sel + (k.name === "up" ? n - 1 : 1)) % n;
|
|
480
|
+
this.sugg.nav = true;
|
|
468
481
|
return this.render();
|
|
469
482
|
}
|
|
470
483
|
if (!ed.vertical(k.name === "up" ? -1 : 1))
|
|
@@ -492,13 +505,25 @@ export class App {
|
|
|
492
505
|
let items = [];
|
|
493
506
|
if (tok.kind === "slash")
|
|
494
507
|
items = rankCommands(tok.query, this.cmds);
|
|
508
|
+
else if (tok.kind === "arg") {
|
|
509
|
+
const cmd = this.cmds.find((c) => c.name === tok.cmd || c.alias === tok.cmd);
|
|
510
|
+
if (cmd) {
|
|
511
|
+
const r = argOptions(this, cmd.name, tok.words ?? []), pk = `${cmd.name}|${(tok.words ?? []).join(" ")}`;
|
|
512
|
+
if (r.pending && !this.argPending.has(pk)) {
|
|
513
|
+
this.argPending.add(pk);
|
|
514
|
+
void r.pending.catch(() => { }).then(() => { this.argPending.delete(pk); this.updateSugg(); this.render(); });
|
|
515
|
+
}
|
|
516
|
+
items = r.options.map((o) => ({ o, s: fuzzyScore(tok.query, o.value) })).filter((x) => x.s !== null)
|
|
517
|
+
.sort((x, y) => (tok.query ? y.s - x.s : 0)).map(({ o }) => ({ label: o.value, insert: o.value + (o.more ? " " : ""), detail: o.detail, more: o.more, open: r.open }));
|
|
518
|
+
}
|
|
519
|
+
}
|
|
495
520
|
else {
|
|
496
521
|
if (!this.files.fresh())
|
|
497
522
|
void this.files.ensure().then(() => this.updateSugg(), () => { }); // list still loading: fill the popup in once it arrives
|
|
498
523
|
items = rankFiles(tok.query, this.files.files);
|
|
499
524
|
}
|
|
500
|
-
const
|
|
501
|
-
this.sugg = items.length ? { items, sel: Math.min(keep, items.length - 1), token: tok, kind: tok.kind } : null;
|
|
525
|
+
const same = this.sugg && this.sugg.kind === tok.kind && this.sugg.token.query === tok.query, keep = same ? this.sugg.sel : 0;
|
|
526
|
+
this.sugg = items.length ? { items, sel: Math.min(keep, items.length - 1), token: tok, kind: tok.kind, nav: same ? this.sugg.nav : false } : null;
|
|
502
527
|
if (!this.sugg)
|
|
503
528
|
return;
|
|
504
529
|
this.schedule();
|
|
@@ -512,6 +537,8 @@ export class App {
|
|
|
512
537
|
this.suggOff = "";
|
|
513
538
|
if (fromEnter && s.kind === "slash" && !this.cmds.find((c) => "/" + c.name === s.items[s.sel].label)?.args)
|
|
514
539
|
return void this.submit();
|
|
540
|
+
if (fromEnter && s.kind === "arg" && !s.items[s.sel].more)
|
|
541
|
+
return void this.submit(); // a finished value: run the command
|
|
515
542
|
this.updateSugg();
|
|
516
543
|
this.render();
|
|
517
544
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { App } from "./app.ts";
|
|
2
|
+
export type ArgOption = {
|
|
3
|
+
value: string;
|
|
4
|
+
detail?: string; /** more words are expected after this one: insert a space and do not submit */
|
|
5
|
+
more?: boolean;
|
|
6
|
+
};
|
|
7
|
+
export type ArgResult = {
|
|
8
|
+
options: ArgOption[];
|
|
9
|
+
pending?: Promise<unknown>; /** any value is allowed (a number, a model id, a command): Enter runs what you typed unless you moved into the list; Tab still completes */
|
|
10
|
+
open?: boolean;
|
|
11
|
+
};
|
|
12
|
+
export declare function argOptions(a: App, cmd: string, words: string[]): ArgResult;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Completion for the words after a slash command: `/mode ` lists the modes, `/model ` the models your provider offers, `/theme ` the themes, and so on.
|
|
2
|
+
// A completer returns its options now and, when it needs the network, a promise that settles when the list has been filled in (the menu refreshes then).
|
|
3
|
+
import { effortsFor, loadPrompts, SANDBOX_MODES, SHELL_CHOICES, STRATEGIES } from "../../src/index.js";
|
|
4
|
+
import { CLAUDE_MODELS, OPENAI_MODELS } from "../config.js";
|
|
5
|
+
import { fetchModels } from "../models.js";
|
|
6
|
+
import { themeNames, THEMES } from "./themes.js";
|
|
7
|
+
const onOff = [{ value: "on" }, { value: "off" }];
|
|
8
|
+
const opts = (...xs) => xs.map(([value, detail]) => ({ value, detail }));
|
|
9
|
+
/** Known models with their hints first, then any other model the provider reports. The provider is asked once per ten minutes; until it answers, only the known models show. */
|
|
10
|
+
const fetched = new Map();
|
|
11
|
+
function modelOptions(a, extra = []) {
|
|
12
|
+
const known = a.cfg.provider === "anthropic" ? CLAUDE_MODELS : a.cfg.provider === "openai" ? OPENAI_MODELS : [];
|
|
13
|
+
const key = `${a.cfg.provider}|${a.cfg.baseUrl}`, have = fetched.get(key), fresh = have && Date.now() - have.at < 10 * 60_000;
|
|
14
|
+
const seen = new Set(known.map((m) => m.id));
|
|
15
|
+
const options = [...extra, ...known.map((m) => ({ value: m.id, detail: m.hint })), ...(have?.ids ?? []).filter((id) => !seen.has(id)).map((id) => ({ value: id, detail: "from your provider" }))];
|
|
16
|
+
if (fresh)
|
|
17
|
+
return { options, open: true };
|
|
18
|
+
return { options, open: true, pending: fetchModels({ provider: a.cfg.provider, baseUrl: a.cfg.baseUrl, apiKey: a.cfg.apiKey }).then((ids) => { fetched.set(key, { at: Date.now(), ids }); }) };
|
|
19
|
+
}
|
|
20
|
+
export function argOptions(a, cmd, words) {
|
|
21
|
+
const first = words.length === 0;
|
|
22
|
+
const ok = (options) => ({ options });
|
|
23
|
+
switch (cmd) {
|
|
24
|
+
case "mode": return first ? ok(opts(["default", "ask before edits and non-trivial commands"], ["acceptEdits", "edits are automatic, commands still ask"], ["plan", "read-only: investigate and propose a plan"], ["yolo", "never ask (dangerous)"])) : ok([]);
|
|
25
|
+
case "api": return first ? ok(opts(["auto", "pick Responses for reasoning models, self-correct"], ["responses", "OpenAI Responses API"], ["chat", "Chat Completions API"])) : ok([]);
|
|
26
|
+
case "effort": return first ? ok([{ value: "default", detail: "let the model decide" }, ...(a.cfg.provider === "anthropic" ? ["low", "medium", "high", "xhigh", "max"] : effortsFor(a.cfg.model)).map((e) => ({ value: e }))]) : ok([]);
|
|
27
|
+
case "model": return first ? modelOptions(a) : ok([]);
|
|
28
|
+
case "models": return first ? ok([...["explore", "compact", "review", "cheap"].map((r) => ({ value: r, detail: `model for ${r} steps`, more: true })), { value: "fallback", detail: "answer from this model when the main one is down", more: true }])
|
|
29
|
+
: words.length === 1 ? modelOptions(a, [{ value: "off", detail: "use the main model" }]) : ok([]);
|
|
30
|
+
case "theme": return first ? ok(themeNames().map((n) => ({ value: n, detail: THEMES[n].label }))) : ok([]);
|
|
31
|
+
case "shell": return first ? ok(SHELL_CHOICES.map((n) => ({ value: n, detail: n === "auto" ? "detect from your system" : undefined }))) : ok([]);
|
|
32
|
+
case "strategy": return first ? ok([...Object.keys(STRATEGIES).map((n) => ({ value: n })), { value: "off", detail: "the default loop" }]) : ok([]);
|
|
33
|
+
case "prompt": return first ? ok([...loadPrompts(a.cwd).map((p) => ({ value: p.id, detail: "from .amness/prompts" })), { value: "list" }, { value: "off", detail: "the built-in prompt" }]) : ok([]);
|
|
34
|
+
case "sandbox": return first ? ok(SANDBOX_MODES.map((m) => ({ value: m, detail: m === "off" ? "no isolation" : m === "workspace" ? "writes only in the project" : "no network, caches read-only" }))) : ok([]);
|
|
35
|
+
case "diffmode": return first ? ok(opts(["auto"], ["stacked"], ["split"])) : ok([]);
|
|
36
|
+
case "ui": return first ? ok(opts(["full", "full-screen"], ["inline", "scrolls with the terminal"])) : ok([]);
|
|
37
|
+
case "mouse":
|
|
38
|
+
case "keepwarm":
|
|
39
|
+
case "lsp":
|
|
40
|
+
case "approver":
|
|
41
|
+
case "reviewer":
|
|
42
|
+
case "router": return first ? ok(onOff) : ok([]);
|
|
43
|
+
case "copy": return first ? ok(opts(["code", "the last code block"], ["all", "the whole conversation"])) : ok([]);
|
|
44
|
+
case "memory": return first ? ok([...["forget", "add"].map((v) => ({ value: v, more: true })), ...["stale", "prune", "consolidate", "graph", "embeddings"].map((v) => ({ value: v }))]) : ok([]);
|
|
45
|
+
case "permissions": return first ? ok([{ value: "list" }, ...["allow", "ask", "deny"].map((v) => ({ value: v, detail: `e.g. /permissions ${v} sh(npm test*)`, more: true }))]) : ok([]);
|
|
46
|
+
case "branch": return first ? ok(a.session.turns.map((t, i) => ({ value: String(i + 1), detail: t.text.replace(/\s+/g, " ").slice(0, 60) }))) : ok([]);
|
|
47
|
+
case "pr": return first ? ok(opts(["draft", "create it as a draft"])) : ok([]);
|
|
48
|
+
case "report": return first ? ok([{ value: "case", detail: "turn the last request into an eval task", more: true }]) : ok([]);
|
|
49
|
+
case "mcp": return first ? ok([{ value: "list" }, { value: "off" }]) : ok([]);
|
|
50
|
+
case "budget": return first ? { options: opts(["100k"], ["300k", "the default"], ["500k"], ["1m"], ["2m"]), open: true } : ok([]);
|
|
51
|
+
case "check":
|
|
52
|
+
case "verify": return first ? { options: [{ value: "off", detail: "switch it off" }], open: true } : ok([]);
|
|
53
|
+
default: return ok([]);
|
|
54
|
+
}
|
|
55
|
+
}
|
package/dist/cli/tui/cards.d.ts
CHANGED
|
@@ -16,6 +16,8 @@ export type RenderCtx = {
|
|
|
16
16
|
diffMode: "auto" | "stacked" | "split";
|
|
17
17
|
/** The block above is also a tool call: draw no blank line between them, so a run of tool calls reads as one unit. */
|
|
18
18
|
tight?: boolean;
|
|
19
|
+
/** this is the most recent tool call of the lead agent: its output stays open (a short preview) until the next one starts */
|
|
20
|
+
last?: boolean;
|
|
19
21
|
};
|
|
20
22
|
export type Hit = {
|
|
21
23
|
row: number;
|
package/dist/cli/tui/cards.js
CHANGED
|
@@ -123,7 +123,8 @@ export function renderBlock(b, c) {
|
|
|
123
123
|
// ---------------------------------------------------------------- tool card
|
|
124
124
|
const sub = !!b.agent && b.agent !== "lead", failed = b.status === "fail", edit = b.edit;
|
|
125
125
|
const hue = failed ? t.err : ({ read_file: t.tool.read, list_files: t.tool.read, grep: t.tool.read, write_file: t.tool.edit, edit_file: t.tool.edit, apply_patch: t.tool.edit, sh: t.tool.shell, web_search: t.tool.web, fetch_url: t.tool.web, memory_save: t.tool.memory, memory_forget: t.tool.memory, memory_search: t.tool.read, todo_write: t.tool.plan, exit_plan_mode: t.tool.plan, task: t.tool.explore })[b.name] ?? (b.name.includes("__") ? t.tool.web : t.tool.generic);
|
|
126
|
-
const expanded = c.expandAll ? true : b.expanded ?? (!!edit || failed);
|
|
126
|
+
const expanded = c.expandAll ? true : b.expanded ?? (!!edit || failed || (!!c.last && !sub && b.name !== "read_file" && b.name !== "task"));
|
|
127
|
+
const preview = !c.expandAll && b.expanded === undefined && !edit && !failed && !!c.last && b.name !== "read_file" && b.name !== "task"; // opened automatically, not by the user: show a short preview only
|
|
127
128
|
const bg = failed ? mix(t.surface, t.err, 0.14) : t.surface, spinner = SPIN[c.frame % SPIN.length];
|
|
128
129
|
const label = BOLD(F(failed ? t.err : t.text, toolLabel(b.name))), args = F(t.dim, argSummary(b.name, b.args));
|
|
129
130
|
const status = b.status === "running"
|
|
@@ -163,7 +164,7 @@ export function renderBlock(b, c) {
|
|
|
163
164
|
}
|
|
164
165
|
else {
|
|
165
166
|
const res = b.result ?? "", hl = (k, s) => F(k === "plain" ? t.syn.plain : t.syn[k], s);
|
|
166
|
-
let lines = res.split("\n"), cap = c.expandAll ? 200 : 60;
|
|
167
|
+
let lines = res.split("\n"), cap = c.expandAll ? 200 : preview ? 20 : 60;
|
|
167
168
|
if (b.name === "sh") {
|
|
168
169
|
let cmd = "";
|
|
169
170
|
try {
|
|
@@ -191,7 +192,7 @@ export function renderBlock(b, c) {
|
|
|
191
192
|
body(w); // only the lines that look like errors are red
|
|
192
193
|
}
|
|
193
194
|
if (lines.length > cap)
|
|
194
|
-
body(F(t.faint, `… ${lines.length - cap} more lines
|
|
195
|
+
body(F(t.faint, `… ${lines.length - cap} more line${lines.length - cap === 1 ? "" : "s"} · /output shows all of it${preview ? "" : " · ctrl+o expands everything"}`));
|
|
195
196
|
}
|
|
196
197
|
}
|
|
197
198
|
return done();
|
package/dist/cli/tui/commands.js
CHANGED
|
@@ -3,8 +3,9 @@ import { relatives } from "./session.js";
|
|
|
3
3
|
import { SHELL_CHOICES, STRATEGIES, chooseShell, currentShell, loadPrompts, setShell, shellFallbackNote } from "../../src/index.js";
|
|
4
4
|
import { join, resolve } from "node:path";
|
|
5
5
|
import { collectFiles, effortsFor, loadMcp, readBundle, writeBundle } from "../../src/index.js";
|
|
6
|
-
import { CLAUDE_MODELS, OPENAI_MODELS, PROVIDERS } from "../config.js";
|
|
7
|
-
import { where } from "../keystore.js";
|
|
6
|
+
import { CLAUDE_MODELS, OPENAI_MODELS, PROVIDERS, connections, keyFor, storeKey } from "../config.js";
|
|
7
|
+
import { noStore, where } from "../keystore.js";
|
|
8
|
+
import { fetchModels } from "../models.js";
|
|
8
9
|
import { bold, cyan, dim, green, red, yellow } from "../ui.js";
|
|
9
10
|
import { estimateCost, fmtCost, fmtDuration, fmtTokens } from "./pricing.js";
|
|
10
11
|
import { codeBlocks } from "./clipboard.js";
|
|
@@ -16,6 +17,29 @@ import { THEMES, getTheme, themeNames } from "./themes.js";
|
|
|
16
17
|
import { helpLines, renderDiff, toolCall, todoList } from "./view.js";
|
|
17
18
|
const INIT_PROMPT = "Explore this repository and write an AGENTS.md at the repo root for future coding sessions: what the project is, how to build/test/run it (exact commands), the code layout, conventions you can verify from the code, and gotchas. Keep it under 80 lines and do not invent anything you cannot confirm from the files.";
|
|
18
19
|
const EFFORTS = ["low", "medium", "high", "xhigh", "max"];
|
|
20
|
+
const keyStatus = (c) => !c.needsKey ? "local, no key needed" : c.source === "env" ? `connected · from ${c.env}` : c.source === "saved" ? `connected · ${noStore() ? "this session only" : where()}` : "not connected";
|
|
21
|
+
/** Make `id` the active provider, using its saved key (or asking for one). Returns false if the user gave up. */
|
|
22
|
+
async function switchProvider(a, id) {
|
|
23
|
+
let apiKey = keyFor(id).key;
|
|
24
|
+
if (PROVIDERS[id].needsKey && !apiKey) {
|
|
25
|
+
apiKey = await a.askText(`${PROVIDERS[id].label} API key (hidden; ${where()})`, { secret: true });
|
|
26
|
+
if (!apiKey)
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
Object.assign(a.cfg, { provider: id, baseUrl: PROVIDERS[id].baseUrl, model: PROVIDERS[id].model, apiKey });
|
|
30
|
+
if (id === "custom" || id === "ollama") {
|
|
31
|
+
const m = await a.askText("Model id", { initial: a.cfg.model });
|
|
32
|
+
if (m)
|
|
33
|
+
a.cfg.model = m;
|
|
34
|
+
const u = await a.askText("Base URL", { initial: a.cfg.baseUrl });
|
|
35
|
+
if (u)
|
|
36
|
+
a.cfg.baseUrl = u;
|
|
37
|
+
}
|
|
38
|
+
a.saveConfig();
|
|
39
|
+
a.buildAgent();
|
|
40
|
+
a.say(`provider → ${cyan(PROVIDERS[id].label)}, model ${cyan(a.cfg.model)}`, dim);
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
19
43
|
export function buildCommands() {
|
|
20
44
|
return [
|
|
21
45
|
{ name: "help", alias: "?", help: "keys and commands", live: true, run: (a) => a.openPager(helpLines(a.cmds, (a.io.output.columns || 80) - 1), "help") },
|
|
@@ -23,7 +47,10 @@ export function buildCommands() {
|
|
|
23
47
|
let id = arg;
|
|
24
48
|
if (!id) {
|
|
25
49
|
const list = a.cfg.provider === "anthropic" ? CLAUDE_MODELS : a.cfg.provider === "openai" ? OPENAI_MODELS : [{ id: a.cfg.model, hint: "current" }];
|
|
26
|
-
const
|
|
50
|
+
const have = new Set(list.map((m) => m.id));
|
|
51
|
+
a.note("asking your provider for its models…", 7000);
|
|
52
|
+
const extra = (await fetchModels({ provider: a.cfg.provider, baseUrl: a.cfg.baseUrl, apiKey: a.cfg.apiKey })).filter((m) => !have.has(m)).map((m) => ({ id: m, hint: "from your provider" }));
|
|
53
|
+
const known = [...list, ...extra].map((m) => ({ label: m.id, value: m.id, hint: m.hint }));
|
|
27
54
|
id = await a.pick("Model", [...known, { label: "Other…", value: "" }], Math.max(0, known.findIndex((m) => m.value === a.cfg.model)));
|
|
28
55
|
if (id === undefined)
|
|
29
56
|
return;
|
|
@@ -42,29 +69,54 @@ export function buildCommands() {
|
|
|
42
69
|
a.buildAgent();
|
|
43
70
|
a.say(`model → ${cyan(id)}`, dim);
|
|
44
71
|
} },
|
|
45
|
-
{ name: "provider", help: "switch provider (OpenAI, Claude, Ollama…)", run: async (a) => {
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
72
|
+
{ name: "provider", help: "switch provider (OpenAI, Claude, Ollama…); each provider keeps its own saved key", run: async (a) => {
|
|
73
|
+
const list = connections();
|
|
74
|
+
const id = await a.pick("Provider", list.map((c) => ({ label: `${!c.needsKey || c.source !== "none" ? "●" : "○"} ${c.label}${c.id === a.cfg.provider ? " (in use)" : ""}`, value: c.id, hint: keyStatus(c) })));
|
|
75
|
+
if (id)
|
|
76
|
+
await switchProvider(a, id);
|
|
77
|
+
} },
|
|
78
|
+
{ name: "connect", alias: "keys", help: "add, replace or remove API keys for several providers and see which are connected", run: async (a) => {
|
|
79
|
+
for (;;) {
|
|
80
|
+
const list = connections();
|
|
81
|
+
const id = await a.pick("Providers ● connected ○ not connected", list.map((c) => ({ label: `${!c.needsKey || c.source !== "none" ? "●" : "○"} ${c.label}${c.id === a.cfg.provider ? " (in use)" : ""}`, value: c.id, hint: keyStatus(c) })));
|
|
82
|
+
if (!id)
|
|
54
83
|
return;
|
|
84
|
+
const c = list.find((x) => x.id === id);
|
|
85
|
+
if (!c.needsKey) {
|
|
86
|
+
a.say(`${c.label} needs no key. Use /provider to switch to it.`, dim);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
const act = c.source === "none" ? "add" : await a.pick("What do you want to do?", [
|
|
90
|
+
...(id === a.cfg.provider ? [] : [{ label: "Use this provider now", value: "use" }]),
|
|
91
|
+
{ label: c.source === "env" ? `Also save a key (${c.env} still wins while it is set)` : "Replace the key", value: "add" },
|
|
92
|
+
...(c.source === "saved" ? [{ label: "Remove the saved key", value: "remove" }] : []),
|
|
93
|
+
]);
|
|
94
|
+
if (!act)
|
|
95
|
+
continue;
|
|
96
|
+
if (act === "use") {
|
|
97
|
+
await switchProvider(a, id);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (act === "add") {
|
|
101
|
+
const key = await a.askText(`${c.label} API key (hidden; ${where()})`, { secret: true });
|
|
102
|
+
if (!key)
|
|
103
|
+
continue;
|
|
104
|
+
storeKey(id, key);
|
|
105
|
+
if (id === a.cfg.provider) {
|
|
106
|
+
a.cfg.apiKey = keyFor(id).key;
|
|
107
|
+
a.buildAgent();
|
|
108
|
+
}
|
|
109
|
+
a.say(`${green("✓")} ${c.label} connected (${noStore() ? "this session only" : where()})`, dim);
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
storeKey(id, undefined);
|
|
113
|
+
if (id === a.cfg.provider) {
|
|
114
|
+
a.cfg.apiKey = keyFor(id).key;
|
|
115
|
+
a.buildAgent();
|
|
116
|
+
}
|
|
117
|
+
a.say(`${c.label}: saved key removed`, dim);
|
|
118
|
+
}
|
|
55
119
|
}
|
|
56
|
-
Object.assign(a.cfg, { provider: id, baseUrl: PROVIDERS[id].baseUrl, model: PROVIDERS[id].model, apiKey });
|
|
57
|
-
if (id === "custom" || id === "ollama") {
|
|
58
|
-
const m = await a.askText("Model id", { initial: a.cfg.model });
|
|
59
|
-
if (m)
|
|
60
|
-
a.cfg.model = m;
|
|
61
|
-
const u = await a.askText("Base URL", { initial: a.cfg.baseUrl });
|
|
62
|
-
if (u)
|
|
63
|
-
a.cfg.baseUrl = u;
|
|
64
|
-
}
|
|
65
|
-
a.saveConfig();
|
|
66
|
-
a.buildAgent();
|
|
67
|
-
a.say(`provider → ${cyan(PROVIDERS[id].label)}, model ${cyan(a.cfg.model)}`, dim);
|
|
68
120
|
} },
|
|
69
121
|
{ name: "effort", help: "reasoning effort for the current model", args: "[level]", run: async (a, arg) => {
|
|
70
122
|
const levels = a.cfg.provider === "anthropic" ? [...EFFORTS] : effortsFor(a.cfg.model);
|
|
@@ -106,6 +158,23 @@ export function buildCommands() {
|
|
|
106
158
|
return void a.say(`unknown mode "${m}"`, red);
|
|
107
159
|
a.setMode(m);
|
|
108
160
|
} },
|
|
161
|
+
{ name: "output", alias: "last", help: "the full output of the last tool call in a pager (/output 2 for the one before)", args: "[n]", live: true, run: (a, arg) => {
|
|
162
|
+
const calls = a.blocks.blocks.filter((b) => b.kind === "tool" && (!b.agent || b.agent === "lead") && !!b.result && b.name !== "todo_write");
|
|
163
|
+
if (!calls.length)
|
|
164
|
+
return void a.say("no tool output yet", dim);
|
|
165
|
+
const n = Math.max(1, Math.floor(Number(arg) || 1)), b = calls[calls.length - n];
|
|
166
|
+
if (!b)
|
|
167
|
+
return void a.say(`only ${calls.length} tool call${calls.length === 1 ? "" : "s"} so far`, dim);
|
|
168
|
+
const body = b.result.split("\n");
|
|
169
|
+
let head = toolCall(b.name, b.args);
|
|
170
|
+
if (b.name === "sh") {
|
|
171
|
+
try {
|
|
172
|
+
head = `$ ${JSON.parse(b.args).cmd}`;
|
|
173
|
+
}
|
|
174
|
+
catch { /* keep the generic header */ }
|
|
175
|
+
}
|
|
176
|
+
a.openPager([head, "", ...body], "output");
|
|
177
|
+
} },
|
|
109
178
|
{ name: "plan", help: "enter plan mode (read-only)", run: (a) => a.setMode("plan") },
|
|
110
179
|
{ name: "clear", help: "start a fresh conversation", alias: "new", run: (a) => {
|
|
111
180
|
a.newConversation();
|
|
@@ -1,16 +1,25 @@
|
|
|
1
1
|
export type Suggestion = {
|
|
2
2
|
label: string;
|
|
3
3
|
insert: string;
|
|
4
|
-
detail?: string;
|
|
4
|
+
detail?: string; /** arg suggestions: more words are expected, so do not submit on Enter */
|
|
5
|
+
more?: boolean; /** arg suggestions: any value is allowed, so Enter runs what was typed unless the user moved into the list */
|
|
6
|
+
open?: boolean; /** category header shown above the first item of each group (only when the list is not filtered) */
|
|
7
|
+
group?: string;
|
|
5
8
|
};
|
|
6
9
|
export type Token = {
|
|
7
|
-
kind: "slash" | "file";
|
|
10
|
+
kind: "slash" | "file" | "arg";
|
|
8
11
|
query: string;
|
|
9
12
|
start: number;
|
|
10
|
-
end: number;
|
|
13
|
+
end: number; /** arg tokens: the command, and the words already typed after it */
|
|
14
|
+
cmd?: string;
|
|
15
|
+
words?: string[];
|
|
11
16
|
};
|
|
12
17
|
/** What is the user completing right now? A slash command (first word of the input) or an @file mention. */
|
|
13
18
|
export declare function tokenAtCursor(text: string, cursor: number): Token | null;
|
|
19
|
+
/** Categories of the built-in commands, in the order the menu shows them. A command that is not listed (yours, or a plugin's) goes under "Custom". */
|
|
20
|
+
export declare const GROUP_ORDER: readonly ["Conversation", "Model & keys", "Safety", "Quality", "Project", "Extend", "Interface", "Custom"];
|
|
21
|
+
export declare const groupOf: (name: string) => string;
|
|
22
|
+
/** All matching commands. With no query they come grouped by category (in GROUP_ORDER, A to Z inside a group); with a query, best match first. */
|
|
14
23
|
export declare function rankCommands(query: string, cmds: {
|
|
15
24
|
name: string;
|
|
16
25
|
help: string;
|
|
@@ -37,3 +46,14 @@ export declare class FileIndex {
|
|
|
37
46
|
private load;
|
|
38
47
|
private walk;
|
|
39
48
|
}
|
|
49
|
+
export type MenuRow = {
|
|
50
|
+
head: string;
|
|
51
|
+
} | {
|
|
52
|
+
item: number;
|
|
53
|
+
};
|
|
54
|
+
/** The rows to draw for a suggestion list: a window of `win` rows that keeps the selected item in view, with a header row above each new group (headers cannot be selected). */
|
|
55
|
+
export declare function menuRows(items: Suggestion[], sel: number, win: number): {
|
|
56
|
+
rows: MenuRow[];
|
|
57
|
+
above: number;
|
|
58
|
+
below: number;
|
|
59
|
+
};
|
package/dist/cli/tui/complete.js
CHANGED
|
@@ -8,17 +8,37 @@ export function tokenAtCursor(text, cursor) {
|
|
|
8
8
|
const slash = before.match(/^\/(\S*)$/);
|
|
9
9
|
if (slash)
|
|
10
10
|
return { kind: "slash", query: slash[1], start: 0, end: cursor };
|
|
11
|
+
const arg = before.match(/^\/(\S+)((?:\s+\S+)*)\s+(\S*)$/); // "/model gp": the command, the words before the last one, the word being typed
|
|
12
|
+
if (arg)
|
|
13
|
+
return { kind: "arg", cmd: arg[1], words: arg[2].trim() ? arg[2].trim().split(/\s+/) : [], query: arg[3], start: cursor - arg[3].length, end: cursor };
|
|
11
14
|
const file = before.match(/(?:^|\s)@([^\s@]*)$/);
|
|
12
15
|
if (file)
|
|
13
16
|
return { kind: "file", query: file[1], start: cursor - file[1].length - 1, end: cursor };
|
|
14
17
|
return null;
|
|
15
18
|
}
|
|
16
|
-
|
|
19
|
+
/** Categories of the built-in commands, in the order the menu shows them. A command that is not listed (yours, or a plugin's) goes under "Custom". */
|
|
20
|
+
export const GROUP_ORDER = ["Conversation", "Model & keys", "Safety", "Quality", "Project", "Extend", "Interface", "Custom"];
|
|
21
|
+
const GROUP_MEMBERS = {};
|
|
22
|
+
for (const [g, names] of Object.entries({
|
|
23
|
+
Conversation: "output clear compact resume branch branches export import undo rewind copy todos exit",
|
|
24
|
+
"Model & keys": "model provider connect effort api models router keepwarm budget tokens",
|
|
25
|
+
Safety: "mode plan permissions policy sandbox approver hooks",
|
|
26
|
+
Quality: "verify check review reviewer lsp report loop diff",
|
|
27
|
+
Project: "init pr tools procs shell status live",
|
|
28
|
+
Extend: "memory mcp skills agents plugins reload-plugins prompt strategy profile",
|
|
29
|
+
Interface: "help theme sidebar details diffmode mouse ui",
|
|
30
|
+
}))
|
|
31
|
+
for (const n of names.split(" "))
|
|
32
|
+
GROUP_MEMBERS[n] = g;
|
|
33
|
+
export const groupOf = (name) => GROUP_MEMBERS[name] ?? "Custom";
|
|
34
|
+
/** All matching commands. With no query they come grouped by category (in GROUP_ORDER, A to Z inside a group); with a query, best match first. */
|
|
35
|
+
export function rankCommands(query, cmds, limit = 500) {
|
|
17
36
|
const q = query.toLowerCase();
|
|
18
37
|
const score = (c) => (c.name.startsWith(q) ? 3 : c.alias?.startsWith(q) ? 2 : c.name.includes(q) || c.alias?.includes(q) ? 1 : 0);
|
|
38
|
+
const gi = (n) => GROUP_ORDER.indexOf(groupOf(n));
|
|
19
39
|
return cmds.map((c) => ({ c, score: score(c) }))
|
|
20
|
-
.filter((x) => x.score > 0 || !q).sort((a, b) => b.score - a.score || a.c.name.localeCompare(b.c.name)).slice(0, limit)
|
|
21
|
-
.map(({ c }) => ({ label: "/" + c.name, insert: "/" + c.name + " ", detail: c.help + (c.alias && !c.name.includes(query.toLowerCase()) && c.alias.includes(query.toLowerCase()) ? ` (also /${c.alias})` : "") }));
|
|
40
|
+
.filter((x) => x.score > 0 || !q).sort((a, b) => (q ? b.score - a.score : gi(a.c.name) - gi(b.c.name)) || a.c.name.localeCompare(b.c.name)).slice(0, limit)
|
|
41
|
+
.map(({ c }) => ({ group: q ? undefined : groupOf(c.name), label: "/" + c.name, insert: "/" + c.name + " ", detail: c.help + (c.alias && !c.name.includes(query.toLowerCase()) && c.alias.includes(query.toLowerCase()) ? ` (also /${c.alias})` : "") }));
|
|
22
42
|
}
|
|
23
43
|
/** Subsequence fuzzy match. Tries the greedy alignment and the exact-substring alignments and keeps the best. null = no match. */
|
|
24
44
|
export function fuzzyScore(query, path) {
|
|
@@ -111,3 +131,18 @@ export class FileIndex {
|
|
|
111
131
|
return out;
|
|
112
132
|
}
|
|
113
133
|
}
|
|
134
|
+
/** The rows to draw for a suggestion list: a window of `win` rows that keeps the selected item in view, with a header row above each new group (headers cannot be selected). */
|
|
135
|
+
export function menuRows(items, sel, win) {
|
|
136
|
+
const all = [];
|
|
137
|
+
let selRow = 0, last;
|
|
138
|
+
items.forEach((it, i) => { if (it.group && it.group !== last)
|
|
139
|
+
all.push({ head: it.group }); last = it.group; if (i === sel)
|
|
140
|
+
selRow = all.length; all.push({ item: i }); });
|
|
141
|
+
win = Math.max(1, win);
|
|
142
|
+
let top = Math.max(0, Math.min(selRow - Math.floor(win / 2), all.length - win));
|
|
143
|
+
if (selRow - 1 >= 0 && "head" in all[selRow - 1] && top > selRow - 1)
|
|
144
|
+
top = selRow - 1; // keep the group title with its first command
|
|
145
|
+
const rows = all.slice(top, top + win);
|
|
146
|
+
const count = (r) => r.filter((x) => "item" in x).length;
|
|
147
|
+
return { rows, above: count(all.slice(0, top)), below: count(all.slice(top + win)) };
|
|
148
|
+
}
|
package/dist/cli/tui/full.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// The full-screen UI: header, scrollable transcript, sidebar, input panel, modals, toasts and mouse handling, drawn on a cell canvas.
|
|
2
2
|
import { copyToClipboard } from "./clipboard.js";
|
|
3
|
+
import { menuRows } from "./complete.js";
|
|
3
4
|
import { basename } from "node:path";
|
|
4
5
|
import { A, mix } from "./ansi.js";
|
|
5
6
|
import { brandOfModel } from "./brand.js";
|
|
@@ -162,6 +163,7 @@ export class FullView {
|
|
|
162
163
|
const live = new Set();
|
|
163
164
|
const blocks = app.blocks.blocks.filter((b) => !(b.kind === "tool" && (b.name === "todo_write" || (b.parent && b.agent && b.agent !== "lead")))); // todos live in the sidebar; a subagent's steps are drawn inside its card
|
|
164
165
|
let prevTool = false;
|
|
166
|
+
const lastTool = [...blocks].reverse().find((b) => b.kind === "tool" && (!b.agent || b.agent === "lead"))?.id; // its output stays open
|
|
165
167
|
for (let i = 0; i < blocks.length; i++) {
|
|
166
168
|
const b = blocks[i];
|
|
167
169
|
live.add(b.id);
|
|
@@ -169,7 +171,7 @@ export class FullView {
|
|
|
169
171
|
if (isQuiet(b))
|
|
170
172
|
while (i + run < blocks.length && isQuiet(blocks[i + run]))
|
|
171
173
|
run++;
|
|
172
|
-
const tctx = { ...ctx, tight: prevTool };
|
|
174
|
+
const tctx = { ...ctx, tight: prevTool, last: b.id === lastTool };
|
|
173
175
|
if (run >= 2) {
|
|
174
176
|
const members = blocks.slice(i, i + run), open = this.expandAll || this.groupOpen.has(b.id);
|
|
175
177
|
const key = `g|${tctx.tight}|${open}|${ctx.theme.name}|${ctx.width}|` + members.map((m) => `${m.id}:${m.status}:${m.ms ?? 0}`).join(",") + (members.some((m) => m.status === "running") ? `|${ctx.frame}` : ""), hit = this.cache.get(b.id);
|
|
@@ -182,7 +184,7 @@ export class FullView {
|
|
|
182
184
|
prevTool = r.rows.length <= 2;
|
|
183
185
|
continue;
|
|
184
186
|
}
|
|
185
|
-
const key = this.sig(b, tctx) + (tctx.tight ? "|t" : ""), hit = this.cache.get(b.id);
|
|
187
|
+
const key = this.sig(b, tctx) + (tctx.tight ? "|t" : "") + (tctx.last ? "|last" : ""), hit = this.cache.get(b.id);
|
|
186
188
|
const r = hit && hit.key === key ? hit.r : (() => { const rr = renderBlock(b, tctx); this.cache.set(b.id, { key, r: rr }); return rr; })();
|
|
187
189
|
this.items.push({ b, r, start: total });
|
|
188
190
|
total += r.rows.length;
|
|
@@ -406,13 +408,19 @@ export class FullView {
|
|
|
406
408
|
const s = this.app.sugg;
|
|
407
409
|
if (!s || !s.items.length || this.app.overlay)
|
|
408
410
|
return;
|
|
409
|
-
const
|
|
410
|
-
if (
|
|
411
|
+
const w = Math.min(86, mainW), room = inputY - 4; // rows between the top of the screen and the box
|
|
412
|
+
if (room < 3)
|
|
411
413
|
return;
|
|
412
|
-
|
|
413
|
-
s.items.
|
|
414
|
-
|
|
415
|
-
|
|
414
|
+
const { rows, above, below } = menuRows(s.items, s.sel, Math.min(14, room)), y = inputY - rows.length - 2;
|
|
415
|
+
const pos = s.items.length > rows.filter((r) => "item" in r).length ? ` ${s.sel + 1}/${s.items.length}${above ? " ↑" : ""}${below ? " ↓" : ""}` : "";
|
|
416
|
+
c.box(x + 1, y, w, rows.length + 2, { border: t.border, bg: t.surface, style: "round", title: (s.kind === "slash" ? "commands" : s.kind === "arg" ? `/${s.token.cmd} options` : "files") + pos, titleFg: t.dim });
|
|
417
|
+
rows.forEach((r, i) => {
|
|
418
|
+
if ("head" in r) {
|
|
419
|
+
c.ansi(x + 2, y + 1 + i, fit(" " + F(t.faint, "── ") + BOLD(F(t.dim, r.head)) + " " + F(t.faint, "─".repeat(Math.max(2, 24 - r.head.length))), w - 2, t.surface), {}, w - 2);
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
const idx = r.item, it = s.items[idx], on = idx === s.sel, bg = on ? t.selection : t.surface;
|
|
423
|
+
const label = s.kind === "file" ? (() => { const n = it.label.split("/").pop(), d = it.label.slice(0, it.label.length - n.length); return F(t.faint, d) + BOLD(F(t.text, n)); })() : BOLD(F(t.accent2, it.label.padEnd(16)));
|
|
416
424
|
c.ansi(x + 2, y + 1 + i, fit((on ? F(t.accent, "▌") : " ") + " " + label + (it.detail ? " " + F(t.faint, it.detail) : ""), w - 2, bg), {}, w - 2);
|
|
417
425
|
this.regions.push({ x: x + 2, y: y + 1 + i, w: w - 2, h: 1, kind: "sugg", click: () => { s.sel = idx; this.app.acceptSuggPublic(true); } });
|
|
418
426
|
});
|
|
@@ -588,6 +596,8 @@ export class FullView {
|
|
|
588
596
|
} return false; }
|
|
589
597
|
/** Scroll keys; returns whether the key was used. */
|
|
590
598
|
key(name, ctrl) {
|
|
599
|
+
if (this.app.sugg && (name === "pageup" || name === "pagedown"))
|
|
600
|
+
return false; // the open menu pages through its list instead
|
|
591
601
|
const page = Math.max(3, Math.floor(this.viewH / 2));
|
|
592
602
|
if (name === "pageup") {
|
|
593
603
|
this.scrollBy(page);
|
package/docs/providers.md
CHANGED
|
@@ -69,6 +69,17 @@ The model list in `/model` (with prices per million tokens as shown in the picke
|
|
|
69
69
|
- If the chat says "check your key with /provider", the provider answered with an authentication error (401 or 403).
|
|
70
70
|
- The test call checks that the model replies. It does not check that the model can call tools.
|
|
71
71
|
|
|
72
|
+
## Connect several providers
|
|
73
|
+
|
|
74
|
+
You can keep a key for every provider at the same time. Each key is saved on its own (in the Keychain, the Linux keyring or DPAPI), so switching provider never overwrites another key.
|
|
75
|
+
|
|
76
|
+
1. In the chat, type `/connect`. You see every provider with `●` (connected) or `○` (not connected), and where the key comes from: a saved key, or an environment variable.
|
|
77
|
+
2. Choose a provider. If it has no key, Amness asks for one (hidden). If it has one, you can use it now, replace the key, or remove the saved key.
|
|
78
|
+
3. The menu stays open, so you can add the next provider. Press `Esc` when you are done.
|
|
79
|
+
4. Switch between them with `/provider`. Amness uses the saved key of the provider you pick, and asks only if there is none.
|
|
80
|
+
|
|
81
|
+
An environment variable wins over a saved key for its own provider: `ANTHROPIC_API_KEY` for Claude, `OPENROUTER_API_KEY` (or `OPENAI_API_KEY`) for OpenRouter, and `OPENAI_API_KEY` for the others. Environment keys are never saved. **Limit:** a key from an older version moves to the provider that was active when you first start the new version, so check `/connect` once.
|
|
82
|
+
|
|
72
83
|
## Switch provider, model or effort
|
|
73
84
|
|
|
74
85
|
**How to switch in the chat.**
|
|
@@ -152,7 +163,8 @@ In `auto` mode Amness fixes these cases for you:
|
|
|
152
163
|
|
|
153
164
|
| Variable | What it does |
|
|
154
165
|
|---|---|
|
|
155
|
-
| `OPENAI_API_KEY` | The key for every provider except `anthropic
|
|
166
|
+
| `OPENAI_API_KEY` | The key for every provider except `anthropic` (and for OpenRouter when `OPENROUTER_API_KEY` is not set). |
|
|
167
|
+
| `OPENROUTER_API_KEY` | The key for `openrouter`. |
|
|
156
168
|
| `ANTHROPIC_API_KEY` | The key for `anthropic`. |
|
|
157
169
|
| `OPENAI_BASE_URL` | The base URL for non-Claude providers. |
|
|
158
170
|
| `AMNESS_MODEL` | The model id. It wins over the saved config. |
|
package/docs/safety.md
CHANGED
|
@@ -40,15 +40,15 @@ Notes about the provider:
|
|
|
40
40
|
|
|
41
41
|
| System | Where the key is saved | Protection |
|
|
42
42
|
|---|---|---|
|
|
43
|
-
| macOS | the login Keychain (service `amness`) | encrypted by macOS |
|
|
43
|
+
| macOS | the login Keychain (service `amness`, one entry per provider, for example `apiKey-anthropic`) | encrypted by macOS |
|
|
44
44
|
| Linux with a desktop keyring | the secret service, through `secret-tool` | encrypted by the keyring |
|
|
45
|
-
| Windows | `~/.amness/secrets/apiKey
|
|
45
|
+
| Windows | `~/.amness/secrets/apiKey-<provider>.dpapi` | encrypted with DPAPI: only your Windows user on this machine can decrypt it |
|
|
46
46
|
| Docker or Kubernetes | nowhere: kept in memory for that run | nothing is written to the image or a volume. Pass the key as an environment variable or a secret file (`ANTHROPIC_API_KEY_FILE=/run/secrets/anthropic`). |
|
|
47
47
|
| Linux server with no keyring | `~/.amness/config.json` with mode 600 | **not encrypted**, only your user can read it. Prefer an environment variable or your platform's secret manager. |
|
|
48
48
|
|
|
49
49
|
- Amness passes the key to the system tool on standard input, never on the command line, where other users could see it in the process list.
|
|
50
50
|
- A key that an older version saved in `config.json` is moved to the key store the next time you start Amness, and removed from the file once the move is checked.
|
|
51
|
-
- To remove a saved key, open the settings and clear "API key", or on macOS run `security delete-generic-password -s amness -a apiKey
|
|
51
|
+
- To remove a saved key, open the settings and clear "API key", or on macOS run `security delete-generic-password -s amness -a apiKey-anthropic` (use `apiKey-openai` and so on for the others).
|
|
52
52
|
- The environment variable wins over the saved key.
|
|
53
53
|
- Amness does not write keys into a project folder.
|
|
54
54
|
- Commands the agent runs do not get your Amness keys. The variables `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `TAVILY_API_KEY`, `OPENROUTER_API_KEY` and `AMNESS_OTLP` are removed from their environment (`childEnv` in `src/secrets.ts`).
|
package/docs/usage.md
CHANGED
|
@@ -92,7 +92,9 @@ amness --inline
|
|
|
92
92
|
- Set the environment variable `AMNESS_UI=inline`.
|
|
93
93
|
- Type `/ui inline` in the chat. `/ui full` goes back.
|
|
94
94
|
|
|
95
|
-
**What you see.** Each tool call is one line, for example `● Read(src/a.ts)`, with a short result. `Ctrl+O` shows everything. Each edit shows a diff with line numbers.
|
|
95
|
+
**What you see.** Each tool call is one line, for example `● Read(src/a.ts)`, with a short result. `Ctrl+O` shows everything. Each edit shows a diff with line numbers.
|
|
96
|
+
|
|
97
|
+
In the full-screen view, the **most recent** tool call stays open. A shell command shows `$ the command` and its output, up to 20 lines. When the next tool call starts, the earlier one folds to one line again. Click a line to open or close it yourself. If the output is longer than 20 lines, the card says how many are hidden. Type `/output` to read all of it in a pager (`/output 2` for the call before). A lone file read does not open itself. A live todo list, a status line (elapsed time and tokens) and a footer (permission mode, git branch, model, context gauge, estimated cost) stay visible. Tool bullets have a colour for each kind and turn red when the tool fails. With `NO_COLOR=1`, or when the output is piped, Amness uses plain text.
|
|
96
98
|
|
|
97
99
|
## Type in the input box
|
|
98
100
|
|
|
@@ -204,14 +206,36 @@ The file is attached to your message. In the transcript you see a note such as "
|
|
|
204
206
|
|
|
205
207
|
## Slash commands
|
|
206
208
|
|
|
207
|
-
Type `/` in the input box to see the popup.
|
|
209
|
+
Type `/` in the input box to see the popup. It lists **every** command, grouped under titles: Conversation, Model & keys, Safety, Quality, Project, Extend and Interface. Your own and plugin commands go under Custom.
|
|
210
|
+
|
|
211
|
+
- Move with `↑` and `↓`. `PgUp` and `PgDn` jump eight at a time. The top of the box shows where you are, for example `12/60 ↓`.
|
|
212
|
+
- Keep typing to filter. The titles disappear and the best match comes first.
|
|
213
|
+
- `Tab` or `Enter` picks the command. `Esc` closes the popup.
|
|
214
|
+
|
|
215
|
+
**Completion after the command.** Type a command and a space, and the popup lists the values it accepts, each with a short explanation:
|
|
216
|
+
|
|
217
|
+
| You type | The popup shows |
|
|
218
|
+
|---|---|
|
|
219
|
+
| `/mode ` | `default`, `acceptEdits`, `plan`, `yolo` |
|
|
220
|
+
| `/model ` | the known models with their hints, then every other model your provider reports (OpenAI, Claude, OpenRouter, Ollama and other OpenAI-compatible servers). Amness asks the provider once and remembers the answer for ten minutes. If the provider cannot be reached, you still see the known models. |
|
|
221
|
+
| `/models ` | the roles (`explore`, `compact`, `review`, `cheap`, `fallback`), then the models |
|
|
222
|
+
| `/effort `, `/api `, `/theme `, `/shell `, `/strategy `, `/prompt `, `/sandbox `, `/diffmode `, `/ui ` | their choices |
|
|
223
|
+
| `/keepwarm `, `/lsp `, `/approver `, `/reviewer `, `/router `, `/mouse ` | `on`, `off` |
|
|
224
|
+
| `/branch ` | your turns, numbered, with the first words of each |
|
|
225
|
+
| `/memory `, `/permissions `, `/copy `, `/pr `, `/report `, `/mcp `, `/budget `, `/check `, `/verify ` | their sub-commands or common values |
|
|
226
|
+
|
|
227
|
+
`Enter` picks the highlighted value and runs the command. For commands that take free text or a number (`/model`, `/budget`, `/check`, `/verify`), `Enter` runs exactly what you typed unless you moved into the list with the arrow keys, so a model id that is not in the list still works. `Tab` always completes. Commands that need more words (`/memory add`, `/permissions allow`) add a space and wait.
|
|
228
|
+
|
|
229
|
+
`/help` lists the commands in the chat.
|
|
208
230
|
|
|
209
231
|
### Models and providers
|
|
210
232
|
|
|
211
233
|
| Command | What it does |
|
|
212
234
|
|---|---|
|
|
213
235
|
| `/model [id]` | Switches the model. Without an id, it opens a picker. |
|
|
214
|
-
| `/
|
|
236
|
+
| `/output [n]` (alias `/last`) | Shows the full output of the last tool call in a pager. `/output 2` shows the one before. |
|
|
237
|
+
| `/provider` | Switches the provider (OpenAI, Claude, OpenRouter, Ollama, custom). The list shows `●` for providers that are connected and `○` for those that are not. Each provider uses its own saved key. |
|
|
238
|
+
| `/connect` (alias `/keys`) | Opens the key manager. See which providers are connected, add a key for any of them, replace or remove one, or switch to it. Add as many as you want. |
|
|
215
239
|
| `/effort [level]` | Sets the reasoning effort. Levels depend on the model. `default` lets the model decide. |
|
|
216
240
|
| `/api [auto\|responses\|chat]` | Chooses the OpenAI wire API. It does not apply to Claude. |
|
|
217
241
|
| `/models [role model]` | Uses another model for `explore`, `compact`, `review` or `cheap`. `/models fallback <model>` sets a model for when the main one is overloaded or not reachable. Use `off` to clear a role. |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "amness",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "A terminal coding agent for any model: streaming chat TUI, permission modes, plan mode, undo, memory, MCP, with a small reliability harness underneath. OpenAI (Responses API), Claude, and local models.",
|
|
6
6
|
"license": "MIT",
|