@mafdet/cli 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +22 -3
  3. package/README.zh-CN.md +17 -1
  4. package/dist/agent/loop.d.ts +17 -0
  5. package/dist/agent/loop.d.ts.map +1 -1
  6. package/dist/agent/loop.js +17 -7
  7. package/dist/agent/session.d.ts +8 -1
  8. package/dist/agent/session.d.ts.map +1 -1
  9. package/dist/agent/session.js +1 -1
  10. package/dist/commands/agent.d.ts +9 -0
  11. package/dist/commands/agent.d.ts.map +1 -1
  12. package/dist/commands/agent.js +3 -3
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +45 -6
  15. package/dist/tui/adapters/auth.d.ts +26 -0
  16. package/dist/tui/adapters/auth.d.ts.map +1 -0
  17. package/dist/tui/adapters/auth.js +76 -0
  18. package/dist/tui/adapters/models.d.ts +47 -0
  19. package/dist/tui/adapters/models.d.ts.map +1 -0
  20. package/dist/tui/adapters/models.js +146 -0
  21. package/dist/tui/agent-surface.d.ts +25 -0
  22. package/dist/tui/agent-surface.d.ts.map +1 -0
  23. package/dist/tui/agent-surface.js +95 -0
  24. package/dist/tui/app.d.ts +25 -0
  25. package/dist/tui/app.d.ts.map +1 -0
  26. package/dist/tui/app.js +159 -0
  27. package/dist/tui/approval.d.ts +27 -0
  28. package/dist/tui/approval.d.ts.map +1 -0
  29. package/dist/tui/approval.js +79 -0
  30. package/dist/tui/banner.d.ts +13 -0
  31. package/dist/tui/banner.d.ts.map +1 -0
  32. package/dist/tui/banner.js +97 -0
  33. package/dist/tui/input.d.ts +17 -0
  34. package/dist/tui/input.d.ts.map +1 -0
  35. package/dist/tui/input.js +57 -0
  36. package/dist/tui/interrupt.d.ts +32 -0
  37. package/dist/tui/interrupt.d.ts.map +1 -0
  38. package/dist/tui/interrupt.js +47 -0
  39. package/dist/tui/keys.d.ts +30 -0
  40. package/dist/tui/keys.d.ts.map +1 -0
  41. package/dist/tui/keys.js +86 -0
  42. package/dist/tui/onboarding.d.ts +28 -0
  43. package/dist/tui/onboarding.d.ts.map +1 -0
  44. package/dist/tui/onboarding.js +117 -0
  45. package/dist/tui/picker.d.ts +24 -0
  46. package/dist/tui/picker.d.ts.map +1 -0
  47. package/dist/tui/picker.js +146 -0
  48. package/dist/tui/resize.d.ts +22 -0
  49. package/dist/tui/resize.d.ts.map +1 -0
  50. package/dist/tui/resize.js +37 -0
  51. package/dist/tui/route.d.ts +26 -0
  52. package/dist/tui/route.d.ts.map +1 -0
  53. package/dist/tui/route.js +68 -0
  54. package/dist/tui/start-agent.d.ts +31 -0
  55. package/dist/tui/start-agent.d.ts.map +1 -0
  56. package/dist/tui/start-agent.js +82 -0
  57. package/dist/tui/terminal.d.ts +41 -0
  58. package/dist/tui/terminal.d.ts.map +1 -0
  59. package/dist/tui/terminal.js +78 -0
  60. package/dist/tui/theme.d.ts +22 -0
  61. package/dist/tui/theme.d.ts.map +1 -0
  62. package/dist/tui/theme.js +59 -0
  63. package/dist/tui/tool-card.d.ts +41 -0
  64. package/dist/tui/tool-card.d.ts.map +1 -0
  65. package/dist/tui/tool-card.js +132 -0
  66. package/dist/tui/widgets.d.ts +44 -0
  67. package/dist/tui/widgets.d.ts.map +1 -0
  68. package/dist/tui/widgets.js +137 -0
  69. package/dist/tui/workspace-summary.d.ts +10 -0
  70. package/dist/tui/workspace-summary.d.ts.map +1 -0
  71. package/dist/tui/workspace-summary.js +49 -0
  72. package/package.json +1 -1
@@ -0,0 +1,146 @@
1
+ // CLI-020 · What the picker may offer, and how a default is remembered.
2
+ //
3
+ // The admission rule is not restated here. `intersectModels` and
4
+ // `isAgentEligible` are the CLI's single answer to "may this model be used,
5
+ // and may the agent drive it", and the picker asks them rather than deciding
6
+ // for itself - a second opinion in the UI is how a UI ends up widening
7
+ // permissions.
8
+ import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
9
+ import { dirname, join } from "node:path";
10
+ import { fetchAccessibleModels, fetchCatalog, intersectModels, } from "../../api/models.js";
11
+ import { buildRuntime } from "../../commands/runtime.js";
12
+ import { parseUserConfig } from "../../config/config.js";
13
+ import { userConfigPath } from "../../config/paths.js";
14
+ import { formatUsd } from "../../output/money.js";
15
+ /**
16
+ * The catalogue does not publish a per-route RPM - checked against the live
17
+ * `/models` response, which has 31 models and no rate field at all. Plan §19.5
18
+ * says to show `unknown` rather than guess from the model id, so this constant
19
+ * exists to make the absence explicit instead of quietly dropping the column.
20
+ */
21
+ export const ROUTE_RPM_AVAILABLE = false;
22
+ export async function loadCatalogue(options) {
23
+ const runtime = buildRuntime({
24
+ ...options.io,
25
+ env: { ...options.io.env, MAFDET_API_KEY: options.apiKey },
26
+ });
27
+ const [accessible, catalog] = await Promise.all([
28
+ fetchAccessibleModels(runtime.dataPlane),
29
+ fetchCatalog(runtime.accountPlane),
30
+ ]);
31
+ const sets = intersectModels(accessible, catalog);
32
+ const chat = sets.available.filter((model) => (model.apiSurface ?? "CHAT_COMPLETIONS") === "CHAT_COMPLETIONS");
33
+ return {
34
+ ...sets,
35
+ chat,
36
+ agentReady: chat.filter((model) => model.agentEligible),
37
+ };
38
+ }
39
+ /** One line of facts under a model's name. Only what the catalogue states. */
40
+ export function describeModel(model) {
41
+ const parts = [];
42
+ if (model.provider !== undefined)
43
+ parts.push(model.provider);
44
+ parts.push(model.apiSurface ?? "CHAT_COMPLETIONS");
45
+ parts.push(model.agentEligible ? "tools verified" : "no verified tools");
46
+ if (model.contextLength !== undefined) {
47
+ parts.push(`${Math.round(model.contextLength / 1000)}K context`);
48
+ }
49
+ const price = priceSummary(model);
50
+ if (price !== undefined)
51
+ parts.push(price);
52
+ if (model.deprecated === true) {
53
+ parts.push(model.replacement_model_id
54
+ ? `DEPRECATED - use ${model.replacement_model_id}`
55
+ : "DEPRECATED");
56
+ }
57
+ return parts.join(" - ");
58
+ }
59
+ function priceSummary(model) {
60
+ const input = model.inputPricePer1mMicroUsd;
61
+ const output = model.outputPricePer1mMicroUsd;
62
+ if (input === undefined || output === undefined)
63
+ return undefined;
64
+ return `${formatUsd(input)}/${formatUsd(output)} per 1M`;
65
+ }
66
+ /**
67
+ * Filter by a typed query. Matches the id, the display name, the provider and
68
+ * the capabilities, so "tool", "deepseek" and "flash" all narrow the list the
69
+ * way a user would expect.
70
+ */
71
+ export function filterModels(models, query) {
72
+ const needle = query.trim().toLowerCase();
73
+ if (needle.length === 0)
74
+ return models;
75
+ return models.filter((model) => {
76
+ const haystack = [
77
+ model.modelId,
78
+ model.displayName ?? "",
79
+ model.provider ?? "",
80
+ model.apiSurface ?? "",
81
+ ...(model.capabilities ?? []),
82
+ model.agentEligible ? "tools agent" : "",
83
+ ]
84
+ .join(" ")
85
+ .toLowerCase();
86
+ return haystack.includes(needle);
87
+ });
88
+ }
89
+ /**
90
+ * Remember a default model in the user config.
91
+ *
92
+ * Only `model` is written, and the existing file is re-validated on the way
93
+ * through, so this can never introduce a credential or an endpoint into a file
94
+ * that is not allowed to carry one. The write is atomic: a crash must not
95
+ * leave a half-written config that the next run refuses to parse.
96
+ */
97
+ export function saveDefaultModel(options) {
98
+ const path = options.configPath ?? userConfigPath(options.env);
99
+ try {
100
+ let existing = {};
101
+ try {
102
+ const raw = JSON.parse(readFileSync(path, "utf8"));
103
+ // Validating first means a file we would refuse to read is also a file
104
+ // we refuse to edit, rather than one we silently rewrite.
105
+ existing = parseUserConfig(raw, path);
106
+ }
107
+ catch (error) {
108
+ if (error.code !== "ENOENT") {
109
+ if (error instanceof SyntaxError) {
110
+ return { saved: false, path, problem: `${path} is not valid JSON` };
111
+ }
112
+ return {
113
+ saved: false,
114
+ path,
115
+ problem: error instanceof Error ? error.message : String(error),
116
+ };
117
+ }
118
+ }
119
+ const next = { ...existing, model: options.modelId };
120
+ mkdirSync(dirname(path), { recursive: true });
121
+ const temp = join(dirname(path), `.${Date.now()}.config.tmp`);
122
+ try {
123
+ writeFileSync(temp, `${JSON.stringify(next, null, 2)}\n`, {
124
+ mode: 0o600,
125
+ });
126
+ renameSync(temp, path);
127
+ }
128
+ catch (error) {
129
+ try {
130
+ unlinkSync(temp);
131
+ }
132
+ catch {
133
+ // The temp file may never have been created.
134
+ }
135
+ throw error;
136
+ }
137
+ return { saved: true, path };
138
+ }
139
+ catch (error) {
140
+ return {
141
+ saved: false,
142
+ path,
143
+ problem: error instanceof Error ? error.message : String(error),
144
+ };
145
+ }
146
+ }
@@ -0,0 +1,25 @@
1
+ import { runSession, type SessionOptions, type SessionSummary } from "../agent/session.js";
2
+ import { type TerminalCapabilities } from "./theme.js";
3
+ import type { WidgetIo } from "./widgets.js";
4
+ export interface AgentSurfaceOptions {
5
+ readonly io: WidgetIo;
6
+ readonly capabilities: TerminalCapabilities;
7
+ readonly stdout: {
8
+ write(text: string): unknown;
9
+ };
10
+ /** Everything runSession needs except the parts this surface supplies. */
11
+ readonly session: Omit<SessionOptions, "stdout" | "stderr" | "readLine" | "approver" | "onEvent">;
12
+ /** Injected by the tests; production uses runSession. */
13
+ readonly run?: typeof runSession;
14
+ }
15
+ /**
16
+ * Read one line of a goal or slash command.
17
+ *
18
+ * A separate reader from the widgets: this one echoes, because a prompt you
19
+ * cannot see what you typed into is unusable, and unlike the key field there
20
+ * is nothing secret here.
21
+ */
22
+ export declare function promptLine(io: WidgetIo, prompt: string): Promise<string | undefined>;
23
+ export declare function runAgentSurface(options: AgentSurfaceOptions): Promise<SessionSummary>;
24
+ export declare function renderSummary(summary: SessionSummary, capabilities: TerminalCapabilities): string;
25
+ //# sourceMappingURL=agent-surface.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"agent-surface.d.ts","sourceRoot":"","sources":["../../src/tui/agent-surface.ts"],"names":[],"mappings":"AAQA,OAAO,EACL,UAAU,EACV,KAAK,cAAc,EACnB,KAAK,cAAc,EACpB,MAAM,qBAAqB,CAAC;AAI7B,OAAO,EAAW,KAAK,oBAAoB,EAAE,MAAM,YAAY,CAAC;AAEhE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,cAAc,CAAC;AAE7C,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,EAAE,EAAE,QAAQ,CAAC;IACtB,QAAQ,CAAC,YAAY,EAAE,oBAAoB,CAAC;IAC5C,QAAQ,CAAC,MAAM,EAAE;QAAE,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAA;KAAE,CAAC;IAClD,0EAA0E;IAC1E,QAAQ,CAAC,OAAO,EAAE,IAAI,CACpB,cAAc,EACd,QAAQ,GAAG,QAAQ,GAAG,UAAU,GAAG,UAAU,GAAG,SAAS,CAC1D,CAAC;IACF,yDAAyD;IACzD,QAAQ,CAAC,GAAG,CAAC,EAAE,OAAO,UAAU,CAAC;CAClC;AAED;;;;;;GAMG;AACH,wBAAsB,UAAU,CAC9B,EAAE,EAAE,QAAQ,EACZ,MAAM,EAAE,MAAM,GACb,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CA+B7B;AAED,wBAAsB,eAAe,CACnC,OAAO,EAAE,mBAAmB,GAC3B,OAAO,CAAC,cAAc,CAAC,CAkCzB;AAED,wBAAgB,aAAa,CAC3B,OAAO,EAAE,cAAc,EACvB,YAAY,EAAE,oBAAoB,GACjC,MAAM,CAgBR"}
@@ -0,0 +1,95 @@
1
+ // CLI-021 · The agent, drawn.
2
+ //
3
+ // This drives `runSession` - the same function `mafdet agent` uses - with its
4
+ // own reader, writers and approver. That is deliberate and it is what the
5
+ // plan's completion criterion asks for: the two surfaces cannot produce
6
+ // different models, tools, approvals or costs, because there is only one
7
+ // implementation of all four. What differs is the drawing.
8
+ import { runSession, } from "../agent/session.js";
9
+ import { formatUsd } from "../output/money.js";
10
+ import { tuiApprover } from "./approval.js";
11
+ import { palette } from "./theme.js";
12
+ import { renderCard, ToolCardLog } from "./tool-card.js";
13
+ /**
14
+ * Read one line of a goal or slash command.
15
+ *
16
+ * A separate reader from the widgets: this one echoes, because a prompt you
17
+ * cannot see what you typed into is unusable, and unlike the key field there
18
+ * is nothing secret here.
19
+ */
20
+ export async function promptLine(io, prompt) {
21
+ const { accent } = palette(io.capabilities);
22
+ let buffer = "";
23
+ const redraw = () => {
24
+ const ESC = String.fromCharCode(27);
25
+ io.write(`${ESC}[2K${ESC}[G${accent(prompt)}${buffer}`);
26
+ };
27
+ redraw();
28
+ for (;;) {
29
+ const keys = await io.nextKeys();
30
+ if (keys === undefined) {
31
+ io.write("\n");
32
+ return undefined;
33
+ }
34
+ for (const key of keys) {
35
+ if (key.kind === "enter") {
36
+ io.write("\n");
37
+ return buffer;
38
+ }
39
+ if (key.kind === "backspace") {
40
+ buffer = buffer.slice(0, -1);
41
+ }
42
+ else if (key.kind === "char") {
43
+ buffer += key.value;
44
+ }
45
+ else if (key.kind === "interrupt" || key.kind === "eof") {
46
+ io.write("\n");
47
+ return undefined;
48
+ }
49
+ }
50
+ redraw();
51
+ }
52
+ }
53
+ export async function runAgentSurface(options) {
54
+ const { io, capabilities } = options;
55
+ const { dim, bold } = palette(capabilities);
56
+ const cards = new ToolCardLog();
57
+ const run = options.run ?? runSession;
58
+ const onEvent = (event) => {
59
+ const changed = cards.apply(event);
60
+ if (changed !== undefined) {
61
+ options.stdout.write(renderCard(changed, capabilities));
62
+ return;
63
+ }
64
+ if (event.type === "rate_limited") {
65
+ // Waiting is not the same as being stuck, and the difference matters
66
+ // when someone is deciding whether to press Ctrl+C.
67
+ options.stdout.write(dim(` rate limited - waiting ${Math.round(event.waitMs / 1000)}s\n`));
68
+ }
69
+ };
70
+ const summary = await run({
71
+ ...options.session,
72
+ stdout: (text) => void options.stdout.write(text),
73
+ // The line session sends progress to stderr so it survives redirection.
74
+ // Here everything is on one terminal, so it goes to the same place.
75
+ stderr: (text) => void options.stdout.write(dim(text)),
76
+ approver: tuiApprover(io),
77
+ readLine: () => promptLine(io, "> "),
78
+ onEvent,
79
+ });
80
+ options.stdout.write(renderSummary(summary, capabilities));
81
+ return summary;
82
+ }
83
+ export function renderSummary(summary, capabilities) {
84
+ const { dim, bold } = palette(capabilities);
85
+ return ([
86
+ "",
87
+ bold(" Session summary"),
88
+ dim(` goals ${summary.goals}`),
89
+ dim(` model requests ${summary.modelRequests}`),
90
+ dim(` tool runs ${summary.toolRuns}`),
91
+ dim(` tokens ${summary.promptTokens} in / ${summary.completionTokens} out`),
92
+ dim(` estimated cost ${formatUsd(summary.costMicroUsd.toString())}`),
93
+ "",
94
+ ].join("\n") + "\n");
95
+ }
@@ -0,0 +1,25 @@
1
+ import { ExitCode } from "../output/exit-codes.js";
2
+ import type { CommandIo } from "../commands/runtime.js";
3
+ import { type GuardHost } from "./terminal.js";
4
+ export interface TuiOptions {
5
+ readonly version: string;
6
+ readonly workspace: string;
7
+ readonly stdout: {
8
+ write(text: string): unknown;
9
+ };
10
+ readonly stderr: {
11
+ write(text: string): unknown;
12
+ };
13
+ readonly stdin: NodeJS.ReadStream | {
14
+ isTTY?: boolean;
15
+ isRaw?: boolean;
16
+ };
17
+ readonly env: Readonly<Record<string, string | undefined>>;
18
+ readonly host: GuardHost;
19
+ readonly columns: number | undefined;
20
+ readonly commandIo: CommandIo;
21
+ /** The bare invocation's flags, for --allow-write / --allow-command. */
22
+ readonly argv: readonly string[];
23
+ }
24
+ export declare function runTui(options: TuiOptions): Promise<ExitCode>;
25
+ //# sourceMappingURL=app.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"app.d.ts","sourceRoot":"","sources":["../../src/tui/app.ts"],"names":[],"mappings":"AAUA,OAAO,EAAE,QAAQ,EAAE,MAAM,yBAAyB,CAAC;AACnD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AAYxD,OAAO,EAAwB,KAAK,SAAS,EAAE,MAAM,eAAe,CAAC;AAQrE,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,MAAM,EAAE;QAAE,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAA;KAAE,CAAC;IAClD,QAAQ,CAAC,MAAM,EAAE;QAAE,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAA;KAAE,CAAC;IAClD,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC,UAAU,GAAG;QAAE,KAAK,CAAC,EAAE,OAAO,CAAC;QAAC,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE,CAAC;IACzE,QAAQ,CAAC,GAAG,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC,CAAC;IAC3D,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC;IACzB,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,SAAS,CAAC;IACrC,QAAQ,CAAC,SAAS,EAAE,SAAS,CAAC;IAC9B,wEAAwE;IACxE,QAAQ,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;CAClC;AAED,wBAAsB,MAAM,CAAC,OAAO,EAAE,UAAU,GAAG,OAAO,CAAC,QAAQ,CAAC,CAuFnE"}
@@ -0,0 +1,159 @@
1
+ // CLI-018/019 · The TUI entry point.
2
+ //
3
+ // The shape is what matters and it is already load-bearing: the TUI owns no
4
+ // API client, no model truth and no permission policy. It renders, collects
5
+ // input, and reaches everything else through the existing adapters. The model
6
+ // picker (CLI-020) and the agent surface (CLI-021) attach after onboarding.
7
+ import { existsSync } from "node:fs";
8
+ import { ExitCode } from "../output/exit-codes.js";
9
+ import { loadCatalogue, saveDefaultModel } from "./adapters/models.js";
10
+ import { renderBanner } from "./banner.js";
11
+ import { keyboard } from "./input.js";
12
+ import { runOnboarding } from "./onboarding.js";
13
+ import { noAgentModelsNotice, pickModel } from "./picker.js";
14
+ import { palette, detectCapabilities, } from "./theme.js";
15
+ import { followResize } from "./resize.js";
16
+ import { installTerminalGuard } from "./terminal.js";
17
+ import { inspectWorkspace, renderWorkspaceSummary, } from "./workspace-summary.js";
18
+ import { parsePermissions, startAgent } from "./start-agent.js";
19
+ import { select } from "./widgets.js";
20
+ export async function runTui(options) {
21
+ // Live, not a snapshot: a window resized mid-session must not keep laying
22
+ // out for the width it used to be.
23
+ const live = followResize(detectCapabilities({
24
+ stdoutIsTTY: true, // decideEntry already established this
25
+ columns: options.columns,
26
+ env: options.env,
27
+ }), options.stdout);
28
+ const capabilities = live.capabilities;
29
+ const guard = installTerminalGuard({
30
+ stdout: options.stdout,
31
+ stdin: options.stdin,
32
+ host: options.host,
33
+ onFatal: (error) => {
34
+ const message = error instanceof Error ? error.message : String(error);
35
+ options.stderr.write(`mafdet: ${message}\n`);
36
+ },
37
+ });
38
+ const keys = keyboard(options.stdin);
39
+ const io = {
40
+ write: (text) => void options.stdout.write(text),
41
+ nextKeys: () => keys.nextKeys(),
42
+ capabilities,
43
+ };
44
+ try {
45
+ options.stdout.write(renderBanner({
46
+ version: options.version,
47
+ workspace: options.workspace,
48
+ capabilities,
49
+ }));
50
+ const auth = await runOnboarding({
51
+ io,
52
+ commandIo: options.commandIo,
53
+ capabilities,
54
+ });
55
+ if (auth.kind === "cancelled") {
56
+ options.stdout.write("\nNothing was changed. Run 'mafdet help' for the\n");
57
+ options.stdout.write("commands that work without an interactive session.\n");
58
+ return ExitCode.Cancelled;
59
+ }
60
+ const chosen = await chooseModel({
61
+ io,
62
+ commandIo: options.commandIo,
63
+ apiKey: auth.apiKey,
64
+ capabilities,
65
+ stdout: options.stdout,
66
+ });
67
+ if (chosen === undefined)
68
+ return ExitCode.Cancelled;
69
+ options.stdout.write(renderWorkspaceSummary(inspectWorkspace(options.workspace, existsSync), capabilities));
70
+ return await startAgent({
71
+ io,
72
+ capabilities,
73
+ stdout: options.stdout,
74
+ commandIo: options.commandIo,
75
+ apiKey: auth.apiKey,
76
+ model: chosen,
77
+ workspace: options.workspace,
78
+ permissions: parsePermissions(options.argv),
79
+ });
80
+ }
81
+ finally {
82
+ live.stop();
83
+ keys.close();
84
+ guard.restore();
85
+ guard.release();
86
+ }
87
+ }
88
+ /**
89
+ * Pick a model, then offer to remember it.
90
+ *
91
+ * Only the model id is ever persisted. The key stays in memory for the life of
92
+ * the process, which is the promise onboarding made.
93
+ */
94
+ async function chooseModel(options) {
95
+ const { dim, bold } = palette(options.capabilities);
96
+ options.stdout.write(dim(" loading the model catalogue...\n"));
97
+ let catalogue;
98
+ try {
99
+ catalogue = await loadCatalogue({
100
+ apiKey: options.apiKey,
101
+ io: options.commandIo,
102
+ });
103
+ }
104
+ catch (error) {
105
+ // A plane being unreachable is a thing to report, not a stack trace to
106
+ // drop on someone. The commands still work, and saying which ones is more
107
+ // use than the exception.
108
+ const message = error instanceof Error ? error.message : String(error);
109
+ options.stdout.write(`\n ${bold("could not load the model list")}: ${message}\n` +
110
+ dim(" the key is fine - this is the catalogue or the network.\n") +
111
+ dim(" 'mafdet doctor' will say which, and 'mafdet chat --model <id>'\n") +
112
+ dim(" still works if you already know the model you want.\n\n"));
113
+ return undefined;
114
+ }
115
+ if (catalogue.chat.length === 0) {
116
+ options.stdout.write(`\n ${bold("this key cannot call any chat model")}\n` +
117
+ dim(" check the key's model permissions in the console, or run\n") +
118
+ dim(" 'mafdet models list' to see what it can reach.\n\n"));
119
+ return undefined;
120
+ }
121
+ if (catalogue.agentReady.length === 0) {
122
+ options.stdout.write(noAgentModelsNotice(catalogue.chat.length, options.capabilities));
123
+ }
124
+ const picked = await pickModel(options.io, {
125
+ models: catalogue.chat,
126
+ // Fail closed: offer only agent-capable models when any exist, so a
127
+ // model the agent cannot drive never becomes the session's default.
128
+ agentOnly: catalogue.agentReady.length > 0,
129
+ current: options.commandIo.env?.MAFDET_MODEL,
130
+ });
131
+ if (picked.kind === "cancelled" || picked.model === undefined)
132
+ return undefined;
133
+ const remember = await select(options.io, {
134
+ title: `Remember ${picked.model.modelId} as your default?`,
135
+ items: [
136
+ {
137
+ value: false,
138
+ label: "Not now",
139
+ detail: "use it for this session only",
140
+ },
141
+ {
142
+ value: true,
143
+ label: "Yes, save it",
144
+ detail: "writes only the model name - never the key",
145
+ },
146
+ ],
147
+ });
148
+ if (remember.kind === "picked" && remember.value === true) {
149
+ const saved = saveDefaultModel({
150
+ modelId: picked.model.modelId,
151
+ env: options.commandIo.env ?? {},
152
+ configPath: options.commandIo.userConfigFile,
153
+ });
154
+ options.stdout.write(saved.saved
155
+ ? dim(` saved to ${saved.path}\n`)
156
+ : dim(` could not save to ${saved.path}: ${saved.problem}\n`));
157
+ }
158
+ return picked.model;
159
+ }
@@ -0,0 +1,27 @@
1
+ import type { ApprovalRequest, Approver } from "../agent/approvals.js";
2
+ import { type TerminalCapabilities } from "./theme.js";
3
+ import { type WidgetIo } from "./widgets.js";
4
+ export interface DiffPanel {
5
+ readonly lines: readonly string[];
6
+ readonly hiddenLines: number;
7
+ }
8
+ /**
9
+ * Prepare a preview for the terminal.
10
+ *
11
+ * The preview is a diff or a command line built from the model's output, so it
12
+ * is stripped of control sequences first. Long previews are cut rather than
13
+ * flooded: a thousand-line diff scrolls the question itself off the screen,
14
+ * and being asked to approve something you can no longer see is worse than
15
+ * being told there is more.
16
+ */
17
+ export declare function diffPanel(preview: string, columns: number): DiffPanel;
18
+ export declare function renderApprovalRequest(request: ApprovalRequest, capabilities: TerminalCapabilities): string;
19
+ /**
20
+ * An approver that shows the change and asks, once, for this change only.
21
+ *
22
+ * Cancelling the prompt is a refusal. That is the same rule the line-based
23
+ * approver uses for a bare Enter: the safe answer has to be the one you get by
24
+ * not thinking about it.
25
+ */
26
+ export declare function tuiApprover(io: WidgetIo): Approver;
27
+ //# sourceMappingURL=approval.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"approval.d.ts","sourceRoot":"","sources":["../../src/tui/approval.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,eAAe,EAAE,QAAQ,EAAE,MAAM,uBAAuB,CAAC;AAEvE,OAAO,EAAW,KAAK,oBAAoB,EAAE,MAAM,YAAY,CAAC;AAChE,OAAO,EAAU,KAAK,QAAQ,EAAE,MAAM,cAAc,CAAC;AAKrD,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,CAAC;IAClC,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;CAC9B;AAED;;;;;;;;GAQG;AACH,wBAAgB,SAAS,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,SAAS,CAMrE;AAQD,wBAAgB,qBAAqB,CACnC,OAAO,EAAE,eAAe,EACxB,YAAY,EAAE,oBAAoB,GACjC,MAAM,CAkBR;AAED;;;;;;GAMG;AACH,wBAAgB,WAAW,CAAC,EAAE,EAAE,QAAQ,GAAG,QAAQ,CAmBlD"}
@@ -0,0 +1,79 @@
1
+ // CLI-021 · The approval panel.
2
+ //
3
+ // This is a renderer for the existing `Approver` interface, not a second
4
+ // approval system. The agent loop still calls `approve()` and still refuses
5
+ // when there is no approver, so the TUI cannot grant anything the line-based
6
+ // session could not. There is no "always allow" here and no session-wide
7
+ // consent: the plan forbids both, and adding one would make every other
8
+ // guarantee about writes conditional on a keystroke someone made once.
9
+ import { stripAnsi } from "../agent/command-policy.js";
10
+ import { palette } from "./theme.js";
11
+ import { select } from "./widgets.js";
12
+ /** Lines of a preview shown before it is collapsed. */
13
+ const PREVIEW_LIMIT = 40;
14
+ /**
15
+ * Prepare a preview for the terminal.
16
+ *
17
+ * The preview is a diff or a command line built from the model's output, so it
18
+ * is stripped of control sequences first. Long previews are cut rather than
19
+ * flooded: a thousand-line diff scrolls the question itself off the screen,
20
+ * and being asked to approve something you can no longer see is worse than
21
+ * being told there is more.
22
+ */
23
+ export function diffPanel(preview, columns) {
24
+ const all = stripAnsi(preview).split("\n");
25
+ const kept = all
26
+ .slice(0, PREVIEW_LIMIT)
27
+ .map((line) => clip(line, columns - 4));
28
+ return { lines: kept, hiddenLines: Math.max(all.length - kept.length, 0) };
29
+ }
30
+ function clip(text, width) {
31
+ const chars = [...text];
32
+ if (width <= 3 || chars.length <= width)
33
+ return text;
34
+ return chars.slice(0, width - 3).join("") + "...";
35
+ }
36
+ export function renderApprovalRequest(request, capabilities) {
37
+ const { bold, dim, accent } = palette(capabilities);
38
+ const panel = diffPanel(request.preview, capabilities.columns);
39
+ const lines = ["", ` ${bold(stripAnsi(request.summary))}`, ""];
40
+ for (const line of panel.lines) {
41
+ // Diff markers get the accent so additions and removals are findable
42
+ // without colour being required to read them.
43
+ lines.push(line.startsWith("+") || line.startsWith("-")
44
+ ? ` ${accent(line)}`
45
+ : ` ${dim(line)}`);
46
+ }
47
+ if (panel.hiddenLines > 0) {
48
+ lines.push(dim(` ... ${panel.hiddenLines} more line(s) not shown`));
49
+ }
50
+ lines.push("");
51
+ return lines.join("\n") + "\n";
52
+ }
53
+ /**
54
+ * An approver that shows the change and asks, once, for this change only.
55
+ *
56
+ * Cancelling the prompt is a refusal. That is the same rule the line-based
57
+ * approver uses for a bare Enter: the safe answer has to be the one you get by
58
+ * not thinking about it.
59
+ */
60
+ export function tuiApprover(io) {
61
+ return {
62
+ async approve(request) {
63
+ io.write(renderApprovalRequest(request, io.capabilities));
64
+ const answer = await select(io, {
65
+ title: "Apply this change?",
66
+ items: [
67
+ { value: false, label: "No", detail: "nothing is changed or run" },
68
+ {
69
+ value: true,
70
+ label: "Yes, once",
71
+ detail: "applies only this change",
72
+ },
73
+ ],
74
+ });
75
+ // Cancelled, ended input, or "No" - all of them mean no.
76
+ return answer.kind === "picked" && answer.value === true;
77
+ },
78
+ };
79
+ }
@@ -0,0 +1,13 @@
1
+ import { type TerminalCapabilities } from "./theme.js";
2
+ export interface BannerInput {
3
+ readonly version: string;
4
+ readonly workspace: string;
5
+ readonly capabilities: TerminalCapabilities;
6
+ }
7
+ /**
8
+ * The wordmark, or a single line when the terminal is too narrow to hold it
9
+ * without wrapping — a wrapped wordmark looks like a rendering bug.
10
+ */
11
+ export declare function wordmark(capabilities: TerminalCapabilities): string[];
12
+ export declare function renderBanner(input: BannerInput): string;
13
+ //# sourceMappingURL=banner.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"banner.d.ts","sourceRoot":"","sources":["../../src/tui/banner.ts"],"names":[],"mappings":"AAOA,OAAO,EAA2B,KAAK,oBAAoB,EAAE,MAAM,YAAY,CAAC;AAqBhF,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,YAAY,EAAE,oBAAoB,CAAC;CAC7C;AASD;;;GAGG;AACH,wBAAgB,QAAQ,CAAC,YAAY,EAAE,oBAAoB,GAAG,MAAM,EAAE,CAOrE;AAuBD,wBAAgB,YAAY,CAAC,KAAK,EAAE,WAAW,GAAG,MAAM,CAyCvD"}