@armadra/agent 0.6.0 → 0.6.1

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 (65) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/CHANGELOG.zh-CN.md +20 -0
  3. package/dist/ai/providers/discovered-cache.d.ts +48 -0
  4. package/dist/ai/providers/discovered-cache.js +102 -0
  5. package/dist/ai/providers/model-visibility.d.ts +33 -0
  6. package/dist/ai/providers/model-visibility.js +70 -0
  7. package/dist/ai/providers/registry.d.ts +2 -0
  8. package/dist/ai/providers/registry.js +6 -1
  9. package/dist/auth/chatgpt/cli.d.ts +3 -1
  10. package/dist/auth/chatgpt/cli.js +31 -4
  11. package/dist/bundle/ama.cjs +1343 -640
  12. package/dist/cli/compose-providers.d.ts +1 -0
  13. package/dist/cli/compose-providers.js +6 -0
  14. package/dist/cli/subcommands/config-set.js +19 -11
  15. package/dist/cli/subcommands/models-discover.d.ts +2 -1
  16. package/dist/cli/subcommands/models-discover.js +17 -1
  17. package/dist/cli/subcommands/models-enable.d.ts +13 -0
  18. package/dist/cli/subcommands/models-enable.js +145 -0
  19. package/dist/cli/subcommands/models.d.ts +2 -0
  20. package/dist/cli/subcommands/models.js +10 -2
  21. package/dist/config/json-schema.js +1 -1
  22. package/dist/config/schema-w5.js +9 -2
  23. package/dist/config/settings-registry.d.ts +3 -0
  24. package/dist/config/settings-registry.js +1 -0
  25. package/dist/config/types-w5.d.ts +7 -0
  26. package/dist/config/types-w5.js +2 -0
  27. package/dist/i18n/catalog.d.ts +43 -3
  28. package/dist/i18n/messages/auth.d.ts +4 -2
  29. package/dist/i18n/messages/auth.js +14 -2
  30. package/dist/i18n/messages/cli-help.js +4 -0
  31. package/dist/i18n/messages/config-keys.d.ts +4 -0
  32. package/dist/i18n/messages/config-keys.js +6 -2
  33. package/dist/i18n/messages/config.d.ts +6 -0
  34. package/dist/i18n/messages/config.js +2 -0
  35. package/dist/i18n/messages/errors.d.ts +1 -1
  36. package/dist/i18n/messages/interactive-startup.d.ts +0 -4
  37. package/dist/i18n/messages/interactive-startup.js +0 -4
  38. package/dist/i18n/messages/interactive.d.ts +0 -4
  39. package/dist/i18n/messages/panels.d.ts +43 -0
  40. package/dist/i18n/messages/panels.js +43 -0
  41. package/dist/i18n/messages/settings.d.ts +7 -0
  42. package/dist/i18n/messages/settings.js +7 -0
  43. package/dist/i18n/messages/subcommands-config.d.ts +2 -0
  44. package/dist/i18n/messages/subcommands-config.js +2 -0
  45. package/dist/i18n/messages/subcommands-models.d.ts +34 -0
  46. package/dist/i18n/messages/subcommands-models.js +34 -0
  47. package/dist/i18n/messages/subcommands.d.ts +28 -0
  48. package/dist/i18n/messages/subcommands.js +3 -0
  49. package/dist/modes/interactive/commands.d.ts +3 -0
  50. package/dist/modes/interactive/commands.js +18 -11
  51. package/dist/modes/interactive/config-ui.js +1 -1
  52. package/dist/modes/interactive/interactive-mode.js +2 -0
  53. package/dist/modes/interactive/model-items.d.ts +45 -0
  54. package/dist/modes/interactive/model-items.js +134 -0
  55. package/dist/modes/interactive/model-picker.d.ts +57 -0
  56. package/dist/modes/interactive/model-picker.js +191 -0
  57. package/dist/modes/interactive/startup-ui.d.ts +3 -9
  58. package/dist/modes/interactive/startup-ui.js +8 -60
  59. package/dist/tui/components/select-list.d.ts +2 -0
  60. package/dist/tui/components/select-list.js +7 -0
  61. package/docs/en/providers.md +2 -0
  62. package/docs/en/tui.md +8 -1
  63. package/docs/providers.md +6 -0
  64. package/docs/tui.md +8 -1
  65. package/package.json +1 -1
@@ -0,0 +1,45 @@
1
+ /**
2
+ * 模型选择器的列表项(`/model`、启动选择器、`/config` 的模型项共用)。
3
+ *
4
+ * - 缺省视图「已配置」:只列有 key、OAuth 已登录或本地的供应商;设置了 `models.enabled` 时只列清单内的
5
+ * (`provider/*` 整个供应商)。「全部」视图(`/model` 里 Tab)另列没配置的供应商,组标题标「未配置 key」。
6
+ * - 当前会话的模型不在视图里时置顶一行,组标题「当前」。
7
+ * - 多渠道供应商每个模型一行(首选渠道),说明里列出其它渠道;`channels`(筛选文本含 `@`)时再列出
8
+ * `model@渠道` 行。清单里逐字写了 `provider/model@渠道` 的渠道行总是列出。
9
+ * - 模型表为空的供应商(ChatGPT 订阅,未缓存发现结果)给一行提示(value 以 `HINT_PREFIX` 开头,不是模型)。
10
+ *
11
+ * 先 `loadModelCatalog`(解析 key,异步)再 `catalogItems`(同步):选择器切视图、改清单时不必重查 key。
12
+ */
13
+ import type { Model, ProviderData, ProviderRegistryApi } from "../../ai/types.js";
14
+ import { type ProviderAccess } from "../../ai/providers/model-visibility.js";
15
+ import type { SelectItem } from "../../tui.js";
16
+ /** 提示行的 value 前缀(`ama:hint:<供应商>`);选中时不是模型。 */
17
+ export declare const HINT_PREFIX = "ama:hint:";
18
+ export declare function isHintValue(value: string): boolean;
19
+ export interface CatalogProvider {
20
+ provider: ProviderData;
21
+ access: ProviderAccess;
22
+ }
23
+ export interface ModelCatalog {
24
+ providers: readonly CatalogProvider[];
25
+ }
26
+ export interface ModelItemOptions {
27
+ /** `configured`(缺省)或 `all`。 */
28
+ view?: "configured" | "all";
29
+ /** `models.enabled`;undefined / 空表示不限。 */
30
+ enabled?: readonly string[] | undefined;
31
+ /** 当前模型 `provider/model[@channel]`。 */
32
+ current?: string | undefined;
33
+ /** 列出 `model@渠道` 行。 */
34
+ channels?: boolean;
35
+ /** 列提示行(缺省 true;启动选择器与 `/config` 关掉)。 */
36
+ hints?: boolean;
37
+ }
38
+ /** 选择器里模型的说明:名称(与 id 不同时)、上下文、`img`(收图片)。 */
39
+ export declare function modelDescription(model: Model): string | undefined;
40
+ export declare function loadModelCatalog(registry: ProviderRegistryApi): Promise<ModelCatalog>;
41
+ export declare function accessLabel(access: ProviderAccess): string;
42
+ /** 目录 → 列表项(规则见文件头)。 */
43
+ export declare function catalogItems(catalog: ModelCatalog, options?: ModelItemOptions): SelectItem[];
44
+ /** 一步到位:解析 key 再生成列表项。 */
45
+ export declare function modelItems(registry: ProviderRegistryApi, options?: ModelItemOptions): Promise<SelectItem[]>;
@@ -0,0 +1,134 @@
1
+ /**
2
+ * 模型选择器的列表项(`/model`、启动选择器、`/config` 的模型项共用)。
3
+ *
4
+ * - 缺省视图「已配置」:只列有 key、OAuth 已登录或本地的供应商;设置了 `models.enabled` 时只列清单内的
5
+ * (`provider/*` 整个供应商)。「全部」视图(`/model` 里 Tab)另列没配置的供应商,组标题标「未配置 key」。
6
+ * - 当前会话的模型不在视图里时置顶一行,组标题「当前」。
7
+ * - 多渠道供应商每个模型一行(首选渠道),说明里列出其它渠道;`channels`(筛选文本含 `@`)时再列出
8
+ * `model@渠道` 行。清单里逐字写了 `provider/model@渠道` 的渠道行总是列出。
9
+ * - 模型表为空的供应商(ChatGPT 订阅,未缓存发现结果)给一行提示(value 以 `HINT_PREFIX` 开头,不是模型)。
10
+ *
11
+ * 先 `loadModelCatalog`(解析 key,异步)再 `catalogItems`(同步):选择器切视图、改清单时不必重查 key。
12
+ */
13
+ import { accessReady, enabledMatch, providerAccess, } from "../../ai/providers/model-visibility.js";
14
+ import { CHATGPT_PROVIDER_ID } from "../../auth/chatgpt/presets.js";
15
+ import { msg } from "../../i18n/index.js";
16
+ /** 提示行的 value 前缀(`ama:hint:<供应商>`);选中时不是模型。 */
17
+ export const HINT_PREFIX = "ama:hint:";
18
+ export function isHintValue(value) {
19
+ return value.startsWith(HINT_PREFIX);
20
+ }
21
+ /** 选择器里模型的说明:名称(与 id 不同时)、上下文、`img`(收图片)。 */
22
+ export function modelDescription(model) {
23
+ const ctx = model.contextWindow;
24
+ const parts = [
25
+ model.name !== "" && model.name !== model.id ? model.name : undefined,
26
+ ctx === undefined
27
+ ? undefined
28
+ : ctx >= 1_000_000
29
+ ? `${Math.round(ctx / 100_000) / 10}M`
30
+ : `${Math.round(ctx / 1000)}k`,
31
+ model.input.includes("image") ? "img" : undefined,
32
+ ].filter((x) => x !== undefined);
33
+ return parts.length > 0 ? parts.join(" · ") : undefined;
34
+ }
35
+ export async function loadModelCatalog(registry) {
36
+ const providers = [];
37
+ for (const provider of registry.list())
38
+ providers.push({ provider, access: await providerAccess(registry, provider) });
39
+ return { providers };
40
+ }
41
+ export function accessLabel(access) {
42
+ const m = msg().panels.model;
43
+ return access === "none" ? m.noKey : m[access];
44
+ }
45
+ /** 模型的其它渠道(首选渠道之外)。 */
46
+ function otherChannels(model) {
47
+ return (model.channels ?? []).filter((c) => c !== "" && c !== model.channel);
48
+ }
49
+ function row(model, group, channel) {
50
+ const suffix = channel === undefined ? "" : `@${channel}`;
51
+ const item = {
52
+ value: `${model.provider}/${model.id}${suffix}`,
53
+ label: `${model.id}${suffix}`,
54
+ group,
55
+ };
56
+ const others = channel === undefined ? otherChannels(model) : [];
57
+ const parts = [
58
+ modelDescription(model),
59
+ others.length > 0
60
+ ? msg().panels.model.otherChannels(others.map((c) => `@${c}`).join(" "))
61
+ : undefined,
62
+ ].filter((x) => x !== undefined);
63
+ if (parts.length > 0)
64
+ item.description = parts.join(" · ");
65
+ return item;
66
+ }
67
+ function hintRow(entry, group) {
68
+ const { provider, access } = entry;
69
+ if (provider.models.length > 0 || !provider.requiresApiKey)
70
+ return undefined;
71
+ const m = msg().panels.model;
72
+ if (access === "key" || access === "oauth")
73
+ return { value: `${HINT_PREFIX}${provider.id}`, label: m.discoverHint(provider.id), group };
74
+ if (provider.id === CHATGPT_PROVIDER_ID)
75
+ return { value: `${HINT_PREFIX}${provider.id}`, label: m.loginHint(provider.id), group };
76
+ return undefined;
77
+ }
78
+ /** 目录 → 列表项(规则见文件头)。 */
79
+ export function catalogItems(catalog, options = {}) {
80
+ const all = options.view === "all";
81
+ const list = options.enabled !== undefined && options.enabled.length > 0 ? options.enabled : undefined;
82
+ const ready = [];
83
+ const rest = [];
84
+ for (const entry of catalog.providers) {
85
+ const { provider, access } = entry;
86
+ const isReady = accessReady(access);
87
+ const group = `${provider.id} · ${accessLabel(access)}`;
88
+ const items = [];
89
+ const listedProvider = list?.includes(`${provider.id}/*`) === true;
90
+ for (const model of provider.models) {
91
+ const main = row(model, group);
92
+ const listed = list !== undefined && enabledMatch(list, main.value) !== undefined;
93
+ const shown = all || (list !== undefined ? listed : isReady);
94
+ if (shown) {
95
+ if (all && listed)
96
+ main.badge = msg().panels.model.listed;
97
+ if (all && listed)
98
+ main.badgeColor = "dim";
99
+ items.push(main);
100
+ }
101
+ for (const channel of otherChannels(model)) {
102
+ const extra = row(model, group, channel);
103
+ const exact = list?.includes(extra.value) === true;
104
+ if (exact || (shown && options.channels === true))
105
+ items.push(extra);
106
+ }
107
+ }
108
+ const hint = options.hints === false ? undefined : hintRow(entry, group);
109
+ if (hint !== undefined && (all || (list !== undefined ? listedProvider : isReady)))
110
+ items.push(hint);
111
+ (isReady ? ready : rest).push(...items);
112
+ }
113
+ return pinCurrent(catalog, [...ready, ...rest], options.current);
114
+ }
115
+ /** 当前模型不在列表里时置顶一行。 */
116
+ function pinCurrent(catalog, items, current) {
117
+ if (current === undefined || current === "" || items.some((i) => i.value === current))
118
+ return items;
119
+ const slash = current.indexOf("/");
120
+ const at = current.lastIndexOf("@");
121
+ const base = at > slash ? current.slice(0, at) : current;
122
+ const model = catalog.providers
123
+ .find((p) => p.provider.id === current.slice(0, slash))
124
+ ?.provider.models.find((m) => `${m.provider}/${m.id}` === base);
125
+ const item = { value: current, label: current, group: msg().panels.model.current };
126
+ const description = model === undefined ? undefined : modelDescription(model);
127
+ if (description !== undefined)
128
+ item.description = description;
129
+ return [item, ...items];
130
+ }
131
+ /** 一步到位:解析 key 再生成列表项。 */
132
+ export async function modelItems(registry, options = {}) {
133
+ return catalogItems(await loadModelCatalog(registry), options);
134
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * 交互模式的 `/model` 选择器:居中覆盖层,列表项见 model-items.ts。
3
+ *
4
+ * - `Tab` 在「已配置 / 全部」两个视图间切换(过滤文本保留);全部视图里没配置的供应商标「未配置 key」,
5
+ * 选中它的模型不切换,选择器底部提示 `ama auth set` / `ama providers add`;
6
+ * - `Space` 把高亮的模型加入 / 移出用户级 `models.enabled`(经 config/edit.ts 写回);只经 `provider/*`
7
+ * 列入的不能单独移出,提示改用 `ama models disable`;
8
+ * - 筛选文本含 `@` 时列出各模型的 `@渠道` 行;
9
+ * - 已在用清单时,从全部视图选中清单外的模型会先把它加入清单再切换;
10
+ * - 提示行(模型表为空的供应商)选中时只在底部显示命令。
11
+ */
12
+ import type { ProviderRegistryApi } from "../../ai/types.js";
13
+ import type { ModeContext } from "../../cli/deps.js";
14
+ import type { Runtime } from "../../cli/runtime.js";
15
+ import { SelectList, type Component, type Focusable } from "../../tui.js";
16
+ import { type ModelCatalog } from "./model-items.js";
17
+ import type { PickerHost } from "./pickers.js";
18
+ export interface ModelPickerOptions {
19
+ title: string;
20
+ providers: ProviderRegistryApi;
21
+ /** 当前模型 `provider/model[@channel]`。 */
22
+ current?: string | undefined;
23
+ /** 打开时的 `models.enabled`。 */
24
+ enabled?: readonly string[] | undefined;
25
+ /** 写回用户级 `models.enabled`(undefined 删除该键);抛错时错误显示在选择器底部。 */
26
+ saveEnabled(next: string[] | undefined): void;
27
+ }
28
+ type View = "configured" | "all";
29
+ /** 选择器本体(可单独测试):`done` 收到选中的模型引用或 undefined(取消)。 */
30
+ export declare class ModelPickerView implements Component, Focusable {
31
+ private readonly host;
32
+ private readonly catalog;
33
+ private readonly options;
34
+ private readonly done;
35
+ private view;
36
+ private enabled;
37
+ private notice;
38
+ private channels;
39
+ readonly list: SelectList;
40
+ constructor(host: PickerHost, catalog: ModelCatalog, options: ModelPickerOptions, done: (ref: string | undefined) => void);
41
+ get focused(): boolean;
42
+ set focused(value: boolean);
43
+ get currentView(): View;
44
+ private rebuild;
45
+ handleInput(data: string): void;
46
+ private providerOf;
47
+ private save;
48
+ private toggle;
49
+ private select;
50
+ render(width: number): string[];
51
+ invalidate(): void;
52
+ }
53
+ /** 打开 `/model` 选择器;返回选中的模型引用,取消返回 undefined。 */
54
+ export declare function openModelPicker(host: PickerHost, options: ModelPickerOptions): Promise<string | undefined>;
55
+ /** 交互模式装配:清单读 `runtime.config`,写用户级 config.json 并换上新配置。 */
56
+ export declare function modelPickerFor(host: PickerHost, runtime: Runtime, context: ModeContext): (providers: ProviderRegistryApi, current: string | undefined) => Promise<string | undefined>;
57
+ export {};
@@ -0,0 +1,191 @@
1
+ /**
2
+ * 交互模式的 `/model` 选择器:居中覆盖层,列表项见 model-items.ts。
3
+ *
4
+ * - `Tab` 在「已配置 / 全部」两个视图间切换(过滤文本保留);全部视图里没配置的供应商标「未配置 key」,
5
+ * 选中它的模型不切换,选择器底部提示 `ama auth set` / `ama providers add`;
6
+ * - `Space` 把高亮的模型加入 / 移出用户级 `models.enabled`(经 config/edit.ts 写回);只经 `provider/*`
7
+ * 列入的不能单独移出,提示改用 `ama models disable`;
8
+ * - 筛选文本含 `@` 时列出各模型的 `@渠道` 行;
9
+ * - 已在用清单时,从全部视图选中清单外的模型会先把它加入清单再切换;
10
+ * - 提示行(模型表为空的供应商)选中时只在底部显示命令。
11
+ */
12
+ import { accessReady, addEnabled, enabledList, enabledMatch, removeEnabled, } from "../../ai/providers/model-visibility.js";
13
+ import { setConfigValue } from "../../config/edit.js";
14
+ import { msg } from "../../i18n/index.js";
15
+ import { Box, SelectList, matchesKey, wrapTextWithAnsi, } from "../../tui.js";
16
+ import { layerInputFor } from "./config-ui.js";
17
+ import { catalogItems, isHintValue, loadModelCatalog } from "./model-items.js";
18
+ /** 选择器本体(可单独测试):`done` 收到选中的模型引用或 undefined(取消)。 */
19
+ export class ModelPickerView {
20
+ host;
21
+ catalog;
22
+ options;
23
+ done;
24
+ view = "configured";
25
+ enabled;
26
+ notice;
27
+ channels = false;
28
+ list;
29
+ constructor(host, catalog, options, done) {
30
+ this.host = host;
31
+ this.catalog = catalog;
32
+ this.options = options;
33
+ this.done = done;
34
+ this.enabled = enabledList(options.enabled);
35
+ this.list = new SelectList([], {
36
+ theme: host.theme,
37
+ maxVisible: 12,
38
+ filterable: true,
39
+ showCount: true,
40
+ ...(host.keybindings !== undefined ? { keybindings: host.keybindings } : {}),
41
+ ...(options.current !== undefined ? { currentValue: options.current } : {}),
42
+ emptyText: msg().panels.model.emptyConfigured,
43
+ onSelect: (item) => this.select(item),
44
+ onCancel: () => this.done(undefined),
45
+ });
46
+ this.rebuild();
47
+ if (options.current !== undefined)
48
+ this.list.selectValue(options.current);
49
+ }
50
+ get focused() {
51
+ return this.list.focused;
52
+ }
53
+ set focused(value) {
54
+ this.list.focused = value;
55
+ }
56
+ get currentView() {
57
+ return this.view;
58
+ }
59
+ rebuild() {
60
+ const m = msg().panels.model;
61
+ this.list.setFooter(this.view === "all" ? m.footerAll : m.footerConfigured);
62
+ this.list.setItems(catalogItems(this.catalog, {
63
+ view: this.view,
64
+ enabled: this.enabled,
65
+ current: this.options.current,
66
+ channels: this.channels,
67
+ }));
68
+ }
69
+ handleInput(data) {
70
+ if (matchesKey(data, "tab")) {
71
+ this.view = this.view === "all" ? "configured" : "all";
72
+ this.notice = undefined;
73
+ this.rebuild();
74
+ return;
75
+ }
76
+ if (data === " ") {
77
+ this.toggle();
78
+ return;
79
+ }
80
+ this.list.handleInput(data);
81
+ const channels = this.list.getFilter().includes("@");
82
+ if (channels !== this.channels) {
83
+ this.channels = channels;
84
+ this.rebuild();
85
+ }
86
+ }
87
+ providerOf(value) {
88
+ const id = value.slice(0, value.indexOf("/"));
89
+ return this.catalog.providers.find((p) => p.provider.id === id);
90
+ }
91
+ save(next) {
92
+ try {
93
+ this.options.saveEnabled(next);
94
+ this.enabled = next;
95
+ return true;
96
+ }
97
+ catch (error) {
98
+ this.notice = {
99
+ text: error instanceof Error ? error.message : String(error),
100
+ level: "error",
101
+ };
102
+ return false;
103
+ }
104
+ }
105
+ toggle() {
106
+ const item = this.list.getSelected();
107
+ if (item === undefined || isHintValue(item.value))
108
+ return;
109
+ const m = msg().panels.model;
110
+ const ref = item.value;
111
+ const match = this.enabled === undefined ? undefined : enabledMatch(this.enabled, ref);
112
+ if (match?.kind === "wildcard") {
113
+ this.notice = { text: m.wildcard(ref, match.pattern), level: "warning" };
114
+ }
115
+ else if (match !== undefined) {
116
+ if (this.save(removeEnabled(this.enabled, [ref])))
117
+ this.notice = { text: m.removed(ref), level: "info" };
118
+ }
119
+ else {
120
+ const first = this.enabled === undefined;
121
+ if (this.save(addEnabled(this.enabled, [ref])))
122
+ this.notice = { text: first ? m.addedFirst(ref) : m.added(ref), level: "info" };
123
+ }
124
+ this.rebuild();
125
+ }
126
+ select(item) {
127
+ const m = msg().panels.model;
128
+ const id = item.value.slice(0, item.value.indexOf("/"));
129
+ if (isHintValue(item.value)) {
130
+ this.notice = { text: item.label, level: "info" };
131
+ return;
132
+ }
133
+ const entry = this.providerOf(item.value);
134
+ if (item.value !== this.options.current && entry !== undefined && !accessReady(entry.access)) {
135
+ const text = entry.access === "needsLogin" ? m.needsLoginHint(id) : m.noKeyHint(id);
136
+ this.notice = { text, level: "warning" };
137
+ return;
138
+ }
139
+ if (this.enabled !== undefined &&
140
+ item.value !== this.options.current &&
141
+ enabledMatch(this.enabled, item.value) === undefined &&
142
+ !this.save(addEnabled(this.enabled, [item.value])))
143
+ return;
144
+ this.done(item.value);
145
+ }
146
+ render(width) {
147
+ const lines = this.list.render(width);
148
+ if (this.notice === undefined)
149
+ return lines;
150
+ const theme = this.host.theme;
151
+ const color = this.notice.level === "info" ? "dim" : this.notice.level;
152
+ return [...lines, ...wrapTextWithAnsi(theme.fg(color, this.notice.text), width)];
153
+ }
154
+ invalidate() {
155
+ this.list.invalidate();
156
+ }
157
+ }
158
+ /** 打开 `/model` 选择器;返回选中的模型引用,取消返回 undefined。 */
159
+ export async function openModelPicker(host, options) {
160
+ const catalog = await loadModelCatalog(options.providers);
161
+ return new Promise((resolve) => {
162
+ let handle;
163
+ const view = new ModelPickerView(host, catalog, options, (ref) => {
164
+ handle?.hide();
165
+ resolve(ref);
166
+ });
167
+ const width = Math.max(20, Math.min(host.columns() - 2, 72));
168
+ handle = host.showOverlay(new Box(view, { title: options.title, theme: host.theme }), {
169
+ anchor: "center",
170
+ width,
171
+ });
172
+ });
173
+ }
174
+ /** 交互模式装配:清单读 `runtime.config`,写用户级 config.json 并换上新配置。 */
175
+ export function modelPickerFor(host, runtime, context) {
176
+ return (providers, current) => openModelPicker(host, {
177
+ title: msg().interactive.commands.modelTitle,
178
+ providers,
179
+ current,
180
+ enabled: runtime.config.models?.enabled,
181
+ saveEnabled: (next) => {
182
+ const result = setConfigValue({
183
+ ...layerInputFor(runtime, context),
184
+ scope: "user",
185
+ key: "models.enabled",
186
+ value: next,
187
+ });
188
+ runtime.replaceConfig?.(result.snapshot.config);
189
+ },
190
+ });
191
+ }
@@ -5,12 +5,13 @@
5
5
  * 选择列表 / 输入框的 `TUI`;答完把问答收成一行再 `stop()`——不清屏,问答留在回滚里。
6
6
  * 取消(Esc / Ctrl+C)按各回调的约定返回 undefined(bootstrap 据此给退出码)。
7
7
  *
8
- * `modelItems` / `sessionItems` 同时供交互模式里的选择器(pickers.ts)使用。
8
+ * `modelItems`(model-items.ts)/ `sessionItems` 同时供交互模式里的选择器(pickers.ts)使用。启动选择器只列
9
+ * 已配置的模型,一个都没有时列全部。
9
10
  */
10
- import type { Model, ProviderRegistryApi } from "../../ai/types.js";
11
11
  import type { InteractiveUi } from "../../cli/deps.js";
12
12
  import type { SessionListItem } from "../../session/types.js";
13
13
  import { type Keybindings, type SelectItem, type Terminal, type Theme } from "../../tui.js";
14
+ export { modelDescription, modelItems } from "./model-items.js";
14
15
  export interface StartupUiOptions {
15
16
  /** 每次问答新建的终端;缺省 `new ProcessTerminal()`。 */
16
17
  terminal?: () => Terminal;
@@ -19,13 +20,6 @@ export interface StartupUiOptions {
19
20
  /** 会话列表的相对时间基准(测试注入)。 */
20
21
  now?: () => number;
21
22
  }
22
- /** 选择器里模型的说明:名称(与 id 不同时)、上下文、`img`(收图片)。 */
23
- export declare function modelDescription(model: Model): string | undefined;
24
- /**
25
- * 模型按「供应商 · 渠道」分组;有 key(或本地)的供应商排前,组标题标 key 状态。多渠道供应商的模型在
26
- * 每个挂载的渠道下各出现一次,非首选渠道的值带 `@渠道`。
27
- */
28
- export declare function modelItems(providers: ProviderRegistryApi): Promise<SelectItem[]>;
29
23
  /** `刚刚 / 5 分钟前 / 3 小时前 / 2 天前 / 2026-09-01`。 */
30
24
  export declare function relativeTime(iso: string, now: number): string;
31
25
  /** 会话:标签 = 名字或首条提示,描述 = 相对时间 · 消息数。 */
@@ -5,12 +5,15 @@
5
5
  * 选择列表 / 输入框的 `TUI`;答完把问答收成一行再 `stop()`——不清屏,问答留在回滚里。
6
6
  * 取消(Esc / Ctrl+C)按各回调的约定返回 undefined(bootstrap 据此给退出码)。
7
7
  *
8
- * `modelItems` / `sessionItems` 同时供交互模式里的选择器(pickers.ts)使用。
8
+ * `modelItems`(model-items.ts)/ `sessionItems` 同时供交互模式里的选择器(pickers.ts)使用。启动选择器只列
9
+ * 已配置的模型,一个都没有时列全部。
9
10
  */
10
11
  import { msg } from "../../i18n/index.js";
11
12
  import { existsSync, statSync } from "node:fs";
12
13
  import { resolve } from "node:path";
13
14
  import { Container, Editor, ProcessTerminal, SelectList, TUI, Text, createTheme, defaultKeybindings, detectCapabilities, } from "../../tui.js";
15
+ import { loadModelCatalog, catalogItems } from "./model-items.js";
16
+ export { modelDescription, modelItems } from "./model-items.js";
14
17
  function resolveOptions(options) {
15
18
  return {
16
19
  terminal: options.terminal ?? (() => new ProcessTerminal()),
@@ -106,64 +109,6 @@ function askText(r, prompt) {
106
109
  // ---------------------------------------------------------------------------
107
110
  // 列表项(选择器共用)
108
111
  // ---------------------------------------------------------------------------
109
- /** 选择器里模型的说明:名称(与 id 不同时)、上下文、`img`(收图片)。 */
110
- export function modelDescription(model) {
111
- const ctx = model.contextWindow;
112
- const parts = [
113
- model.name !== "" && model.name !== model.id ? model.name : undefined,
114
- ctx === undefined
115
- ? undefined
116
- : ctx >= 1_000_000
117
- ? `${Math.round(ctx / 100_000) / 10}M`
118
- : `${Math.round(ctx / 1000)}k`,
119
- model.input.includes("image") ? "img" : undefined,
120
- ].filter((x) => x !== undefined);
121
- return parts.length > 0 ? parts.join(" · ") : undefined;
122
- }
123
- /**
124
- * 模型按「供应商 · 渠道」分组;有 key(或本地)的供应商排前,组标题标 key 状态。多渠道供应商的模型在
125
- * 每个挂载的渠道下各出现一次,非首选渠道的值带 `@渠道`。
126
- */
127
- export async function modelItems(providers) {
128
- const groups = [];
129
- for (const provider of providers.list()) {
130
- if (provider.models.length === 0)
131
- continue;
132
- let status;
133
- let ready = true;
134
- if (!provider.requiresApiKey)
135
- status = msg().interactive.startup.ui.local;
136
- else {
137
- const key = await providers.resolveApiKey(provider.id).catch(() => ({ apiKey: undefined }));
138
- ready = key.apiKey !== undefined;
139
- status = ready ? "key ✓" : msg().interactive.startup.ui.noKey;
140
- }
141
- const item = (model, group, channel) => {
142
- const suffix = channel !== undefined && channel !== model.channel ? `@${channel}` : "";
143
- const out = {
144
- value: `${provider.id}/${model.id}${suffix}`,
145
- label: `${model.id}${suffix}`,
146
- group,
147
- };
148
- const description = modelDescription(model);
149
- if (description !== undefined)
150
- out.description = description;
151
- return out;
152
- };
153
- if (provider.channels === undefined) {
154
- const group = `${provider.id} · ${status}`;
155
- groups.push({ ready, items: provider.models.map((model) => item(model, group)) });
156
- continue;
157
- }
158
- for (const channel of provider.channels) {
159
- const group = `${provider.id} · ${channel.name} · ${status}`;
160
- const models = provider.models.filter((m) => m.channels?.includes(channel.name));
161
- if (models.length > 0)
162
- groups.push({ ready, items: models.map((model) => item(model, group, channel.name)) });
163
- }
164
- }
165
- return [...groups.filter((g) => g.ready), ...groups.filter((g) => !g.ready)].flatMap((g) => g.items);
166
- }
167
112
  /** `刚刚 / 5 分钟前 / 3 小时前 / 2 天前 / 2026-09-01`。 */
168
113
  export function relativeTime(iso, now) {
169
114
  const at = Date.parse(iso);
@@ -247,7 +192,10 @@ export function createStartupUi(options = {}) {
247
192
  return picked?.value;
248
193
  },
249
194
  async pickModel(providers, reason) {
250
- const items = await modelItems(providers);
195
+ const catalog = await loadModelCatalog(providers);
196
+ let items = catalogItems(catalog, { hints: false });
197
+ if (items.length === 0)
198
+ items = catalogItems(catalog, { view: "all", hints: false });
251
199
  if (items.length === 0)
252
200
  return undefined;
253
201
  const picked = await askSelect(r, {
@@ -60,6 +60,8 @@ export declare class SelectList implements Component, Focusable {
60
60
  private get maxVisible();
61
61
  setItems(items: readonly SelectItem[]): void;
62
62
  getItems(): readonly SelectItem[];
63
+ /** 换列表下的按键提示(选择器切视图时)。 */
64
+ setFooter(footer: string | undefined): void;
63
65
  getFilter(): string;
64
66
  setFilter(filter: string): void;
65
67
  getSelected(): SelectItem | undefined;
@@ -52,6 +52,13 @@ export class SelectList {
52
52
  getItems() {
53
53
  return this.filtered;
54
54
  }
55
+ /** 换列表下的按键提示(选择器切视图时)。 */
56
+ setFooter(footer) {
57
+ if (footer === undefined)
58
+ delete this.options.footer;
59
+ else
60
+ this.options.footer = footer;
61
+ }
55
62
  getFilter() {
56
63
  return this.filter;
57
64
  }
@@ -170,6 +170,8 @@ ama auth logout chatgpt # siwc revokes the refresh token first, t
170
170
  | Quota | only known when exceeded (429); set a weekly cap for ama under ChatGPT → Settings → Usage → App limits | response headers, `codex.rate_limits` events, `ama auth status` queries `wham/usage` |
171
171
  | Logout | calls `revocation_endpoint`, then deletes locally | deletes locally only |
172
172
 
173
+ **Model list**: `chatgpt` has no built-in model table (`chatgpt/<slug>` accepts any slug). After a successful `ama auth login chatgpt`, ama calls the model list endpoint once (siwc `GET /v1/models`, codex `GET /models`; read-only, no usage consumed; failures are silent and the output suggests `ama models discover chatgpt` instead) and caches the slugs and display names available to the account, with the flavor and a timestamp, in `<dataDir>/models/discovered/chatgpt.json`; `ama models discover chatgpt` rewrites the cache and `ama auth logout chatgpt` deletes it. When the registry is assembled the cache is merged into providers whose model table is empty, with metadata filled from the models.dev snapshot, so the `/model` picker and `ama models list` show the models; slugs missing from the cache still work with `--model chatgpt/<slug>`.
174
+
173
175
  The default channel of `chatgpt` is chosen at assembly time from the flavor of the auth.json entry; `provider/model@siwc|codex` can name it explicitly but must match the signed-in flavor (otherwise `chatgpt_flavor_mismatch`).
174
176
 
175
177
  **Credentials**: a `{ "type": "oauth", … }` entry in auth.json (file mode 0600); `ama auth list` only shows `oauth · <flavor> · <plan>`. The access token is refreshed automatically when less than 5 minutes remain or a request returns 401; several ama processes (several nodes on the canvas) share one auth.json, and refreshes are serialized through `auth.json.lock` (re-read after taking the lock; if another process already refreshed, its token is used), so refresh-token rotation never knocks another process out. When refreshing fails permanently the entry is marked `needsLogin` (tokens are not deleted), requests report `auth_expired`, and you sign in again with `ama auth login chatgpt` as prompted. Raw tokens, codes and id_tokens never reach logs, sessions, events or errors. Logins of other applications (Codex CLI and others) are never read or imported.
package/docs/en/tui.md CHANGED
@@ -188,7 +188,14 @@ Design in [rewind-plan.md](../rewind-plan.md) (Chinese). Every user message that
188
188
  - `/fork` (no arguments): also picks a user message, then copies a new session up to before it.
189
189
  - `/model`, `/resume`, `/permission` and `/thinking` without arguments open pickers; `/permissions` shows the permission decision order, loaded rules and the latest 20 auto decisions (tier, result, reason).
190
190
  - The `/permission` picker, titled permission mode: Manual / Accept edits / Plan / Auto / Bypass permissions / Allowlist only, each with a one-line explanation and number keys 1–6 on the right for direct selection; the current mode is checked `✓`, the default mode from config is marked `Default`, Auto is marked `Recommended`, with a key hint line at the bottom. `/permission auto` and `/permission Accept edits` switch directly. The far left of the status bar is the mode's display name. When auto mode needs confirmation, the approval dialog has an extra line naming the auto rule tier / classifier and the reason. Details in [permissions.md](permissions.md).
191
- - Pickers have a key hint line at the bottom (`↑↓ select · Enter confirm · Esc cancel`) and a background on the selected row (≥ 256 colors; accent bold with fewer). The `/model` picker checks the current model with `✓` and shows an `(i/n)` count; it groups by "provider · channel" (multi-channel models get one item per channel, non-preferred channels marked `@channel`), and descriptions include context size and `img` (accepts images); `/model packy/kimi-k2.5@messages` switches to a specific channel directly.
191
+ - Pickers have a key hint line at the bottom (`↑↓ select · Enter confirm · Esc cancel`) and a background on the selected row (≥ 256 colors; accent bold with fewer).
192
+ - The `/model` picker (also `Ctrl+L`) checks the current model with `✓`, shows an `(i/n)` count, and descriptions include context size and `img` (accepts images):
193
+ - **Only configured providers by default**: a key, a valid OAuth sign-in (not one that needs signing in again) or a local server; group titles are "provider · status". When the current model is not in the list it is pinned at the top (group "current").
194
+ - **`Tab` switches to "all"** (press again to go back; the filter text is kept): providers without configuration are listed too, marked "no key configured"; picking one of their models does not switch and the bottom line suggests `ama auth set <provider>` (custom providers: `ama providers add`). `Tab` was chosen over an "add model" item at the end of the list: it takes no list row, never shows up in filter results, and works in the middle of filtering.
195
+ - **`Space` adds / removes the highlighted model to / from the list** (user-level `models.enabled`, written back to config.json): once the list is set, the "configured" view shows only listed models (plus the current one); removing the last entry deletes the key. Models listed through `provider/*` cannot be removed one by one; the hint points to `ama models disable`. While a list is in use, picking an unlisted model from "all" adds it to the list and then switches. Because of this `Space` no longer splits filter terms (the filter matches one term; `provider/model` works as a filter).
196
+ - **Multi-channel providers get one row per model** (the preferred channel); the description says "also @channel". With an `@` in the filter text (such as `@messages`) the `model@channel` rows are listed and picking one uses that channel. `@` was chosen over expanding with `→`: it matches the `provider/model@channel` syntax and works the same way in the startup picker. `/model packy/kimi-k2.5@messages` still switches to a specific channel directly.
197
+ - **ChatGPT subscription**: after `ama auth login chatgpt` the models available to the account are fetched (the read-only model list endpoint, no usage consumed) and cached in `<dataDir>/models/discovered/chatgpt.json`; the picker lists them like any other model. Without the cache the chatgpt group has a row "Run ama models discover chatgpt to list its models" (see [providers.md](providers.md#chatgpt-login)).
198
+ - The same list from the command line: `ama models enable <provider/model[@channel]|provider/*>…`, `ama models disable …`, `ama models list --enabled` (without a list it prints what the picker shows by default).
192
199
  - An `@image-path` in the input (quotes allowed, Tab completes the path), or a pasted / dropped image file path, is sent as an image attachment with the message; when the current model does not accept images an `@` attachment is an error and nothing is sent, while paths without `@` are ignored (see [providers.md](providers.md) "Image input").
193
200
  - `/statusline [full|compact]`: switch the bottom info line (without arguments it toggles, same as `Ctrl+G`), this session only; line mode has no bottom info line.
194
201
  - `/session`, `/cache`: session usage and cache stats panels (see "Cache and context" above); `/permissions` is a panel too, with allow in green, deny in red and the decision order wrapped and aligned. With sub-agent tasks `/session` gains a "Sub-agents" line (task count and states), and after using external agents an "External agents" section (runs and usage per agent, USD / tokens / requests each in its own unit, never converted).
package/docs/providers.md CHANGED
@@ -185,6 +185,12 @@ ama auth logout chatgpt # siwc 先撤销 refresh token 再删本
185
185
  | 配额 | 只在超限(429)时可知;在 ChatGPT → 设置 → Usage → App limits 给 ama 设周上限 | 响应头、`codex.rate_limits` 事件、`ama auth status` 查 `wham/usage` |
186
186
  | 登出 | 调 `revocation_endpoint` 撤销,再删本地 | 只删本地 |
187
187
 
188
+ **模型列表**:`chatgpt` 没有内置模型表(`chatgpt/<slug>` 任意接受)。`ama auth login chatgpt` 成功后自动调一次模型
189
+ 列表接口(siwc `GET /v1/models`、codex `GET /models`,只读、不消耗额度;失败静默,改提示 `ama models discover chatgpt`),
190
+ 把账户可用的 slug 与显示名连同 flavor、时间戳缓存到 `<dataDir>/models/discovered/chatgpt.json`;`ama models discover
191
+ chatgpt` 也重写这份缓存,`ama auth logout chatgpt` 删掉它。组装注册表时缓存并入模型表为空的供应商,元数据用 models.dev
192
+ 快照补全,`/model` 选择器、`ama models list` 照常列出;缓存里没有的 slug 仍可 `--model chatgpt/<slug>` 使用。
193
+
188
194
  `chatgpt` 的缺省渠道在组装时按 auth.json 条目的 flavor 决定;`provider/model@siwc|codex` 可显式指定,但必须与登录的
189
195
  flavor 一致(否则报 `chatgpt_flavor_mismatch`)。
190
196
 
package/docs/tui.md CHANGED
@@ -182,7 +182,14 @@ Accept edits claude-opus-5-5 medium | Ctx 3.0% | proj ⎇ main 5ae9e54 (+12,
182
182
  - `/fork`(无参数):同样选一条用户消息,从它之前复制出新会话。
183
183
  - `/model`、`/resume`、`/permission`、`/thinking` 不带参数时打开选择器;`/permissions` 显示权限判定顺序、已加载规则与最近 20 条 auto 判定(层、结果、原因)。
184
184
  - `/permission` 选择器标题「权限模式」:Manual / Accept edits / Plan / Auto / Bypass permissions / Allowlist only,每项一行说明,右侧数字 1–6 直接选;当前模式打 `✓`,配置里的缺省模式标 `Default`,Auto 标 `Recommended`,底部一行按键提示。`/permission auto`、`/permission Accept edits` 直接切换。状态栏最左是模式的显示名。auto 模式下需要确认时,审批对话框多一行「Auto 规则层 / 分类器:原因」。详见 [permissions.md](permissions.md)。
185
- - 选择器底部一行按键提示(`↑↓ 选择 · Enter 确认 · Esc 取消`),选中行有底色(≥ 256 色;更少时强调色粗体)。`/model` 选择器给当前模型打 `✓`,并带 `(i/n)` 计数;按「供应商 · 渠道」分组(多渠道模型在每个渠道下各一项,非首选渠道带 `@渠道`),说明里有上下文与 `img`(收图片);`/model packy/kimi-k2.5@messages` 直接切到指定渠道。
185
+ - 选择器底部一行按键提示(`↑↓ 选择 · Enter 确认 · Esc 取消`),选中行有底色(≥ 256 色;更少时强调色粗体)。
186
+ - `/model` 选择器(`Ctrl+L` 同)给当前模型打 `✓`,并带 `(i/n)` 计数,说明里有上下文与 `img`(收图片):
187
+ - **缺省只列已配置的供应商**:有 key、OAuth 已登录(需重新登录的不算)或本地服务;组标题是「供应商 · 状态」。当前模型不在列表里时置顶一行(组「当前」)。
188
+ - **`Tab` 切到「全部」**(再按切回,筛选文本保留):另列没配置的供应商,组标题标「未配置 key」;选中它们的模型不切换,底部提示 `ama auth set <供应商>`(自定义供应商用 `ama providers add`)。选 `Tab` 而不是列表底部的「添加模型」项:不占列表位置、不混进筛选结果,筛到一半也能切。
189
+ - **`Space` 把高亮的模型加入 / 移出清单**(用户级 `models.enabled`,写回 config.json):设置了清单后「已配置」视图只列清单内的模型(加当前模型);清单移空时删掉这个键。经 `provider/*` 列入的不能单独移出,按提示用 `ama models disable`。已在用清单时,从「全部」选中清单外的模型会先加入清单再切换。`Space` 因此不再用作筛选的分词(筛选按一个词匹配,`provider/model` 也能筛)。
190
+ - **多渠道供应商每个模型一行**(首选渠道),说明里写「另有 @渠道」;筛选文本里带 `@`(如 `@messages`)时列出 `模型@渠道` 行,直接选中即用该渠道。选 `@` 而不是 `→` 展开:与 `provider/model@channel` 的写法一致,启动选择器里也能用同一习惯。`/model packy/kimi-k2.5@messages` 仍可直接切到指定渠道。
191
+ - **ChatGPT 订阅**:`ama auth login chatgpt` 后自动拉取账户可用的模型(只读的模型列表接口,不消耗额度)缓存到 `<dataDir>/models/discovered/chatgpt.json`,选择器里按普通模型列出;没缓存时 chatgpt 组有一行「运行 ama models discover chatgpt 获取模型」(见 [providers.md](providers.md#chatgpt-登录))。
192
+ - 命令行同一份清单:`ama models enable <provider/model[@channel]|provider/*>…`、`ama models disable …`、`ama models list --enabled`(没设清单时列出选择器缺省显示的模型)。
186
193
  - 输入里的 `@图片路径`(可加引号,Tab 补全路径)或粘贴 / 拖入的图片文件路径作为图片附件随消息发送;当前模型不收图片时 `@` 附件报错、不发送,未加 `@` 的路径忽略(见 [providers.md](providers.md)「图像输入」)。
187
194
  - `/statusline [full|compact]`:切换底部信息行(无参数时在两者间切换,同 `Ctrl+G`),只影响本会话;line 模式没有底部信息行。
188
195
  - `/session`、`/cache`:会话用量与缓存统计面板(见上文「缓存与上下文」);`/permissions` 同样是面板,allow 绿、deny 红,判定顺序折行对齐。`/session` 有子 Agent 任务时多一行「子 Agent」(任务数与各状态),用过外部 Agent 时多一段「外部 Agent」(每个 Agent 的运行次数与用量,美元 / token / 请求数按各自单位,不换算)。