Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions COMMANDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ Every run ends with a verdict line: `✓ ok · 4 files changed · tests green ·
|---|---|
| `--local` | Use the local brain (Python/Ollama) instead of the cloud. |
| `--pool <gb>` | Context pool size in GB (status-bar reach = pool × 233M tokens). |
| `--effort <t>` | Effort tier: `LOW` \| `MED` \| `MAX` \| `ULTRA` \| `CODEPRO` (overrides the saved `/effort` dial). |
| `--effort <t>` | Effort tier: `LOW` \| `MED` \| `HIGH` \| `MAX` \| `ULTRA` \| `CODEPRO` (overrides the saved `/effort` dial). |
| `--test-cmd <c>` | Command the verification gate runs (unverified without it). |
| `--quiet` | Plain output (strip the personality frames). |
| `--interactive` | Pause at each stage boundary to type a steer (TTY only). |
Expand Down Expand Up @@ -188,7 +188,7 @@ aether config set autoApply true
|---|---|---|
| `baseUrl` | string | Aether API base URL. |
| `defaultModel` | string | Model used when `--model` is omitted. |
| `defaultEffort` | string | Effort tier for `aether code` when `--effort` is omitted (`LOW`\|`MED`\|`MAX`\|`ULTRA`\|`CODEPRO`, `""` = server default). Same dial as `/effort`. |
| `defaultEffort` | string | Effort tier for `aether code` when `--effort` is omitted (`LOW`\|`MED`\|`HIGH`\|`MAX`\|`ULTRA`\|`CODEPRO`, `""` = server default). Same dial as `/effort`. |
| `permissionMode` | `ask`\|`auto`\|`skip` | Gate edits/commands: prompt every time, auto with confirm, or fully autonomous. |
| `autoApply` | bool | Apply streamed edits without a per-edit prompt. |
| `telemetry` | bool | Anonymous usage telemetry opt-in. |
Expand All @@ -211,7 +211,7 @@ mirrors the live registry in `src/commands/slash_registry.ts`.
| `/agents` | View active agent sessions (name, status, time, UVT, task). |
| `/agent <n\|id>` | Switch orchestrator (Neo / Kronus) — opens the picker with no arg. |
| `/tier` | Show your plan tier, default, and available counts. |
| `/effort [tier\|1-5]` | Show or set the effort dial (`LOW`→`CODEPRO`). Persists to your Aether config and drives `aether code`. `CODEPRO` gets the banner. |
| `/effort [tier\|1-6]` | Show or set the effort dial (`LOW`→`CODEPRO`). The dial moves phases, sub-agent fan-out, repair passes and the UVT ceiling; `CODEPRO` additionally enables System-2 review and unlimited context, and gets the banner. Persists to your Aether config and drives `aether code`. |
| `/audit [n]` | Recent chain-of-custody receipts. |
| `/doctor [deep]` | Run ordered diagnostics; `deep` adds bounded checks. |
| `/clear` | Clear the screen. |
Expand Down
16 changes: 8 additions & 8 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@
"url": "https://github.com/DBarr3/aether-agent/issues"
},
"devDependencies": {
"@types/node": "^26.1.1",
"@types/node": "^24.0.0",
"typescript": "^7.0.2"
}
}
Expand Down
89 changes: 78 additions & 11 deletions src/commands/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,19 @@
// Source: GET /models (lib/plan_tiers.TIER_MATRIX SSOT). One list, `kind`
// distinguishes models from orchestrators. Locked (unavailable-on-tier) rows
// are shown with a lock so users see what an upgrade unlocks.
//
// Layout note: columns are padded by VISIBLE width, not string length. The
// marker column mixes "●" (one column) with "🔒" (two), and ids and labels vary
// in length, so the previous tab-separated rows drifted out of alignment the
// moment an id crossed a tab stop — putting the lock in a different place on
// every row, which is exactly where the eye needs it to be steady.

import type { AppContext } from "../core/context.js";
import type { CatalogResponse } from "../types.js";
import type { CatalogItem, CatalogResponse } from "../types.js";
import { MODELS_PATH } from "../core/transport.js";
import { saveConfig } from "../core/config.js";
import { theme } from "../ui/theme.js";
import { visibleWidth } from "../ui/text.js";

export async function cmdModels(ctx: AppContext, argv: string[]): Promise<number> {
const sub = argv[0];
Expand All @@ -31,10 +39,15 @@ export async function cmdModels(ctx: AppContext, argv: string[]): Promise<number
return 0;
}
const activeDefault = ctx.cfg.defaultModel || cat.default;
process.stdout.write(`tier: ${cat.tier} default: ${cat.default}\n`);
for (const m of cat.models) {
process.stdout.write(renderRow(m, activeDefault));
const models = cat.models.filter((m) => m.kind !== "orchestrator");
const orchestrators = cat.models.filter((m) => m.kind === "orchestrator");

process.stdout.write(renderHeader(cat.tier, activeDefault));
if (models.length) process.stdout.write(renderGroup("MODELS", models, activeDefault));
if (orchestrators.length) {
process.stdout.write(renderGroup("ORCHESTRATORS", orchestrators, activeDefault));
}
process.stdout.write(renderLegend(cat.models, "aether models use <id>"));
return 0;
}

Expand All @@ -45,15 +58,69 @@ export async function cmdAgents(ctx: AppContext): Promise<number> {
process.stdout.write(JSON.stringify(orchestrators, null, 2) + "\n");
return 0;
}
for (const a of orchestrators) {
process.stdout.write(renderRow(a, ctx.cfg.defaultModel || cat.default));
const activeDefault = ctx.cfg.defaultModel || cat.default;
process.stdout.write(renderHeader(cat.tier, activeDefault));
if (orchestrators.length) {
process.stdout.write(renderGroup("ORCHESTRATORS", orchestrators, activeDefault));
}
process.stdout.write(renderLegend(orchestrators, "aether agent <id>"));
return 0;
}

function renderRow(m: CatalogResponse["models"][number], activeDefault: string): string {
const mark = m.id === activeDefault ? "*" : m.available ? " " : "🔒";
const kind = m.kind === "orchestrator" ? "orch " : "model";
const cap = m.monthly_uvt_cap != null ? ` cap ${m.monthly_uvt_cap}` : "";
return `${mark} ${m.id}\t${kind}\t${m.tier_min ?? "-"}\t${m.label}${cap}\n`;
// ── rendering ───────────────────────────────────────────────────────────────

/** Pad to `w` VISIBLE columns. Styled text and wide glyphs both pad correctly. */
export function padVisible(s: string, w: number): string {
return s + " ".repeat(Math.max(0, w - visibleWidth(s)));
}

/** 128000 -> "128k", 1000000 -> "1M". Long digit runs read as noise in a column. */
export function compactNumber(n: number): string {
if (n >= 1_000_000) return `${Math.round(n / 100_000) / 10}M`.replace(".0M", "M");
if (n >= 1_000) return `${Math.round(n / 100) / 10}k`.replace(".0k", "k");
return String(n);
}

function renderHeader(tier: string, activeDefault: string): string {
return (
"\n " +
theme.dim("tier ") + theme.bold(theme.cyan(tier)) +
theme.dim(" default ") + theme.bold(activeDefault) +
"\n"
);
}

function renderGroup(title: string, items: CatalogItem[], activeDefault: string): string {
// Size every column to its widest cell, so nothing truncates and nothing drifts.
const idW = Math.max(...items.map((m) => visibleWidth(m.id)), 4);
const provW = Math.max(...items.map((m) => visibleWidth(m.provider ?? "")), 0);
const labelW = Math.max(...items.map((m) => visibleWidth(m.label)), 0);

const rows = items.map((m) => {
const isDefault = m.id === activeDefault;
// The marker occupies two columns in every state, so the id column starts at
// the same offset whether or not the row is locked.
const marker = isDefault ? theme.cyan("● ") : m.available ? " " : "🔒";
const id = isDefault ? theme.bold(theme.cyan(m.id)) : m.available ? m.id : theme.dim(m.id);
const meta = [
padVisible(theme.dim(m.provider ?? ""), provW),
padVisible(m.available ? m.label : theme.dim(m.label), labelW),
m.context_window != null ? theme.dim(compactNumber(m.context_window)) : "",
];
// The one thing a locked row has to answer is "what unlocks it".
const gate = !m.available && m.tier_min ? theme.yellow(`needs ${m.tier_min}`) : "";
const cap = m.available && m.monthly_uvt_cap != null
? theme.dim(`cap ${compactNumber(m.monthly_uvt_cap)}`)
: "";
return ` ${marker} ${padVisible(id, idW)} ${meta.join(" ")} ${gate || cap}`.trimEnd();
});

return `\n ${theme.iceBlue(title)}\n${rows.join("\n")}\n`;
}

function renderLegend(items: CatalogItem[], useHint: string): string {
const locked = items.filter((m) => !m.available).length;
const parts = [theme.cyan("●") + theme.dim(" default")];
if (locked) parts.push("🔒" + theme.dim(` ${locked} locked on your tier`));
return `\n ${parts.join(theme.dim(" · "))}\n ${theme.dim(useHint)}\n\n`;
}
4 changes: 3 additions & 1 deletion src/commands/slash_help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ export function printSlashHelp(out: Writable, target = ""): void {
sections: SLASH_SECTIONS,
target: target.trim().replace(/^\//, ""),
footer: [
"/help <command> for detail ? Tab completes slash commands.",
// The separator here was a literal "?" — a mangled "·" that shipped as a
// question mark and read as part of the sentence.
"/help <command> for detail · /help <word> searches · Tab completes slash commands.",
"/model or /agent with no argument opens the picker.",
],
}));
Expand Down
2 changes: 1 addition & 1 deletion src/commands/slash_registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export const SLASH_COMMANDS: SlashCommand[] = [
{ name: "tier", summary: "plan tier + default model", section: "Session" },
// effort tier persists to config and rides TaskCommand.effort into every
// `aether code` run — see setEffort() in slash.ts for the wire contract.
{ name: "effort", args: "[tier|1-5]", summary: "effort dial (LOW to CODEPRO), drives aether code", section: "Session" },
{ name: "effort", args: "[tier|1-6]", summary: "effort dial (LOW to CODEPRO), drives aether code", section: "Session" },
{ name: "audit", args: "[n]", summary: "recent audit trail", section: "Session" },
{ name: "doctor", args: "[deep]", summary: "structured runtime diagnostics", section: "Session" },
{ name: "clear", summary: "clear screen", section: "Session" },
Expand Down
100 changes: 84 additions & 16 deletions src/core/command_registry.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { theme } from "../ui/theme.js";
import { visibleWidth } from "../ui/text.js";

export interface CommandSpec {
name: string;
args?: string;
Expand Down Expand Up @@ -67,35 +70,100 @@ export interface HelpOptions {
footer?: string[];
}

/** Pad to `w` VISIBLE columns — styled text carries zero-width escapes. */
function padCol(s: string, w: number): string {
return s + " ".repeat(Math.max(0, w - visibleWidth(s)));
}

/** `<prefix><name> <args>` — the canonical way a command is written. */
function usageOf(command: CommandSpec, prefix: string): string {
return `${prefix}${command.name}${command.args ? " " + command.args : ""}`;
}

export function renderRegistryHelp(options: HelpOptions): string {
const target = options.target?.trim();
if (target) {
const command = findRegisteredCommand(options.commands, target);
if (!command) {
const suggestion = suggestRegisteredCommand(target, commandNames(options.commands));
return `Unknown command: ${options.prefix}${target}${suggestion ? `\nDid you mean ${options.prefix}${suggestion}?` : ""}\n`;
}
const aliases = command.aliases?.length ? `\nAliases: ${command.aliases.map((alias) => options.prefix + alias).join(", ")}` : "";
return `Usage: ${options.prefix}${command.name}${command.args ? " " + command.args : ""}\n${command.summary}\nSection: ${command.section}${aliases}\n`;
if (target) return renderTargetHelp(options, target);

const lines = [theme.bold(options.title)];
if (options.intro) lines.push("", theme.dim(options.intro));
if (options.usage?.length) {
lines.push("", theme.dim("Usage:"), ...options.usage.map((line) => " " + line));
}
const lines = [options.title];
if (options.intro) lines.push("", options.intro);
if (options.usage?.length) lines.push("", "Usage:", ...options.usage.map((line) => " " + line));
for (const section of options.sections) {
const commands = options.commands.filter((command) => command.section === section && !command.hidden);
if (!commands.length) continue;
lines.push("", section + ":");
lines.push("", theme.iceBlue(section + ":"));
const usages = commands.map((command) => {
const aliases = command.aliases?.length ? ` (${command.aliases.map((alias) => options.prefix + alias).join(", ")})` : "";
return `${options.prefix}${command.name}${command.args ? " " + command.args : ""}${aliases}`;
return usageOf(command, options.prefix) + aliases;
});
const width = Math.min(42, Math.max(...usages.map((usage) => usage.length)) + 2);
commands.forEach((command, index) => lines.push(` ${usages[index]!.padEnd(width)}${command.summary}`));
// Was capped at 42 columns: any usage longer than that then butted straight
// against its own summary with no separating space. Pad to the widest usage
// actually present, so the summary column is straight and never collides.
const width = Math.max(...usages.map((usage) => visibleWidth(usage))) + 2;
commands.forEach((command, index) =>
lines.push(` ${padCol(theme.cyan(usages[index]!), width)}${theme.dim(command.summary)}`),
);
}
if (options.footer?.length) lines.push("", ...options.footer);
if (options.footer?.length) lines.push("", ...options.footer.map((line) => theme.dim(line)));
return lines.join("\n") + "\n";
}

/**
* `help <target>`. Three outcomes, in order:
*
* 1. exact name or alias -> full detail for that command
* 2. substring of a name or summary -> the commands that matched
* 3. nothing -> unknown, plus the closest did-you-mean
*
* Step 2 is the addition. Previously anything that was not an exact name was
* "Unknown command", so `help vault` or `help git` — a user describing what they
* want rather than naming it — hit a dead end standing next to a registry that
* could have answered. Search runs AFTER exact match, so a real command name
* always wins, and falls through to did-you-mean when it finds nothing.
*/
function renderTargetHelp(options: HelpOptions, target: string): string {
const command = findRegisteredCommand(options.commands, target);
if (command) {
const aliases = command.aliases?.length
? `\nAliases: ${command.aliases.map((alias) => options.prefix + alias).join(", ")}`
: "";
// Neighbours in the same section: the answer to "what else is near this?",
// which is usually the next question after reading one command's detail.
const siblings = options.commands
.filter((c) => c.section === command.section && c.name !== command.name && !c.hidden)
.map((c) => options.prefix + c.name);
const related = siblings.length ? `\n${theme.dim("Related: " + siblings.join(" "))}` : "";
return (
`Usage: ${theme.bold(usageOf(command, options.prefix))}\n` +
`${command.summary}\n` +
`Section: ${command.section}${aliases}${related}\n`
);
}

const needle = target.toLowerCase();
const matches = options.commands.filter(
(c) =>
!c.hidden &&
(c.name.includes(needle) ||
c.summary.toLowerCase().includes(needle) ||
c.section.toLowerCase().includes(needle) ||
c.aliases?.some((a) => a.includes(needle))),
);
if (matches.length) {
const usages = matches.map((c) => usageOf(c, options.prefix));
const width = Math.max(...usages.map((u) => visibleWidth(u))) + 2;
const rows = matches.map(
(c, i) => ` ${padCol(theme.cyan(usages[i]!), width)}${theme.dim(c.summary)}`,
);
const header = `No command named ${options.prefix}${target} — ${matches.length} match${matches.length === 1 ? "" : "es"}:`;
return [header, "", ...rows, "", theme.dim(`${options.prefix}help <name> for detail.`)].join("\n") + "\n";
}

const suggestion = suggestRegisteredCommand(target, commandNames(options.commands));
return `Unknown command: ${options.prefix}${target}${suggestion ? `\nDid you mean ${options.prefix}${suggestion}?` : ""}\n`;
}

function editDistance(a: string, b: string, max: number): number {
if (Math.abs(a.length - b.length) > max) return max + 1;
let previous = Array.from({ length: b.length + 1 }, (_, index) => index);
Expand Down
30 changes: 28 additions & 2 deletions src/ui/effort.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,30 @@
import { theme } from "./theme.js";
import { KAOMOJI } from "./kaomoji.js";

export const EFFORT_TIERS = ["LOW", "MED", "MAX", "ULTRA", "CODEPRO"] as const;
// Must stay identical to the orchestrator's closed set —
// lib/orchestrator/presets/contracts.py:EFFORT_TIERS. HIGH was missing here, so
// the dial could not reach a tier the backend accepts and no CLI user could
// select it. Names are what travel on the wire; the 1-based numeric shortcut is
// a local convenience, which is why adding HIGH shifts /effort 5 from CODEPRO to
// ULTRA. The slider echoes the resolved tier on every set, so a mis-numbered
// entry is visible immediately.
export const EFFORT_TIERS = ["LOW", "MED", "HIGH", "MAX", "ULTRA", "CODEPRO"] as const;
export type EffortTier = (typeof EFFORT_TIERS)[number];

/**
* What the dial actually moves, in the orchestrator's own terms
* (lib/orchestrator/presets/effort.py:EffectiveEffortPolicy). An effort profile
* may only REDUCE these envelopes, or re-bind planner/synthesizer/arbiter to
* models the preset already allows — it never grants new capability. Kept
* deliberately concrete: "more effort = better" tells a user nothing about what
* they are spending.
*/
export const EFFORT_LEGEND = "phases · sub-agent fan-out · repair passes · UVT ceiling";

/** CODEPRO alone flips System-2 review (workflow_engine.py:94) and unlimited
* context (deep_thinking.py:26) — a capability change, not just a bigger budget. */
const CODEPRO_LEGEND = "+ System-2 review · unlimited context";

/** Accepts a tier name (any case) or a 1-based index; null if unrecognized.
* Coerces defensively: a hand-edited config can carry a number or null. */
export function normalizeEffort(input: string): EffortTier | null {
Expand Down Expand Up @@ -55,7 +76,12 @@ export function renderEffortSlider(current: string): string[] {
labels += i === idx && tier ? theme.bold(theme.cyan(seg)) : theme.dim(seg);
});

return [title, track, labels];
// Fourth line: what moving the dial actually buys. Without it the slider is a
// pretty control with no stated units — a user can see they are at MAX and
// still not know what MAX costs or grants.
const legend = " " + theme.dim(pro ? `${EFFORT_LEGEND} ${CODEPRO_LEGEND}` : EFFORT_LEGEND);

return [title, track, labels, legend];
}

// Block-letter CODEPRO — kept ≤ 80 cols, no combining chars (width-safe).
Expand Down
Loading