@master.g/pi-wingman 0.0.1 → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,7 +10,7 @@
10
10
  | **advisor** | oh-my-pi advisor | 指定一个模型当顾问,读主 agent 的每个 turn,以 steer 消息内联注入便签(aside / concern / blocker)让主 agent 纠偏 |
11
11
  | **hint** | Claude Code prompt suggestions | 任务结束后按上下文与目标生成下一步 prompt,输入框 ghost text 呈现:Tab 填入、Enter 直接发送 |
12
12
 
13
- > 状态:骨架(v0.0.1)。命令与配置已可用;三个控制器按 M1 hint → M2 advisor → M3 heads-up 接入。
13
+ > 状态:早期版本(v0.0.2)。hint、advisor、heads-up 三个功能均已实现,默认关闭,尚在试用打磨中。
14
14
 
15
15
  ## 安装
16
16
 
@@ -27,7 +27,7 @@ pi --extension ~/github/pi-wingman/index.ts
27
27
  ## 命令
28
28
 
29
29
  - `/wingman` — 查看三个功能的状态与模型
30
- - `/advisor`、`/hint`、`/headsup` — 各接 `<provider/model>`(开启并设模型,模型须存在)、`on` / `off`(开关,保留已设模型)、无参(看状态);参数支持补全
30
+ - `/wingman advisor|hint|headsup` — 不带值时弹出 pi 的模型选择器(同 `/model`,可搜索),选完模型再选思考强度(同 `/thinking`,Esc 跳过);也可直接写 `<provider/model> [effort]`(模型须存在,effort 须是该模型支持的档位)、`on` / `off`(开关,保留已设模型与 effort);参数支持补全
31
31
  - `/ask-advisor <问题>` — 就当前上下文咨询顾问模型,回答注入对话
32
32
 
33
33
  ## 配置
@@ -37,12 +37,12 @@ pi --extension ~/github/pi-wingman/index.ts
37
37
  ```json
38
38
  {
39
39
  "headsup": { "enabled": false },
40
- "advisor": { "enabled": true, "model": "openai-codex/gpt-5.5" },
40
+ "advisor": { "enabled": true, "model": "openai-codex/gpt-5.5", "effort": "low" },
41
41
  "hint": { "enabled": false }
42
42
  }
43
43
  ```
44
44
 
45
- 三个功能默认都关闭:每个功能都会额外调用模型,需要用户自己开启。模型形如 `provider/model-id`;hint 没配模型时用当前模型(`/hint on`),advisor/heads-up 必须配模型才会激活。
45
+ 三个功能默认都关闭:每个功能都会额外调用模型,需要用户自己开启。模型形如 `provider/model-id`;hint 没配模型时用当前模型(`/wingman hint on`),advisor/heads-up 必须配模型才会激活。`effort` 可选(off / minimal / low / medium / high / xhigh / max,按模型支持的档位校正),不设则不开推理。advisor/heads-up 在后台跑,单次调用超时 60 秒;hint 要求快,超时 10 秒,高 effort 多半超时,超时就不出 hint。
46
46
 
47
47
  ## License
48
48
 
package/advisor.ts CHANGED
@@ -72,7 +72,8 @@ export function installAdvisor(pi: ExtensionAPI, getConfig: () => WingmanConfig,
72
72
  registry,
73
73
  model,
74
74
  { goal: getGoal(), digest },
75
- { timeoutMs: 15_000, signal: shutdown.signal },
75
+ // 后台审计不卡主流程,超时放宽给高 effort 留余地
76
+ { timeoutMs: 60_000, signal: shutdown.signal, effort: getConfig().advisor.effort },
76
77
  );
77
78
  if (!verdict || verdict.level === "ok") return;
78
79
  ctx.ui.notify(`advisor [${verdict.level}] ${verdict.summary}`, NOTIFY_TYPE[verdict.level]);
@@ -86,7 +87,7 @@ export function installAdvisor(pi: ExtensionAPI, getConfig: () => WingmanConfig,
86
87
 
87
88
  pi.on("turn_end", (event, ctx) => {
88
89
  const config = getConfig().advisor;
89
- // advisor 必须显式配置模型才激活(开启必经 /advisor <model>);
90
+ // advisor 必须显式配置模型才激活(开启必经 /wingman advisor <model>);
90
91
  // 未设或拼错模型都不回退当前模型,避免双份成本。
91
92
  if (!config.enabled || !config.model) return;
92
93
  if (!state.shouldAuditTurn()) return;
@@ -108,7 +109,7 @@ export function installAdvisor(pi: ExtensionAPI, getConfig: () => WingmanConfig,
108
109
  handler: async (args, ctx) => {
109
110
  const config = getConfig().advisor;
110
111
  if (!config.enabled || !config.model) {
111
- ctx.ui.notify("advisor 未启用,先 /advisor <provider/model> 配置", "error");
112
+ ctx.ui.notify("advisor 未启用,先 /wingman advisor <provider/model> 配置", "error");
112
113
  return;
113
114
  }
114
115
  const question = args.trim();
@@ -144,7 +145,7 @@ export function installAdvisor(pi: ExtensionAPI, getConfig: () => WingmanConfig,
144
145
  },
145
146
  ],
146
147
  },
147
- { timeoutMs: 30_000, signal: shutdown.signal },
148
+ { timeoutMs: 60_000, signal: shutdown.signal, effort: config.effort },
148
149
  );
149
150
  if (!result.ok) {
150
151
  ctx.ui.notify(`advisor 调用失败:${result.reason}`, "error");
package/audit.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  * undefined);失败一律静默(返回 undefined),不抛进 pi 事件循环。
10
10
  */
11
11
 
12
- import { completeText, messageText, type AnyModel, type Message, type ModelRegistry } from "./llm.js";
12
+ import { completeText, messageText, type AnyModel, type CallOptions, type Message, type ModelRegistry } from "./llm.js";
13
13
 
14
14
  export type VerdictLevel = "ok" | "note" | "concern" | "blocker";
15
15
 
@@ -92,7 +92,7 @@ export async function runAudit(
92
92
  registry: ModelRegistry,
93
93
  model: AnyModel,
94
94
  input: { goal: string; digest: string },
95
- options: { timeoutMs?: number; signal?: AbortSignal } = {},
95
+ options: CallOptions = {},
96
96
  ): Promise<Verdict | undefined> {
97
97
  const goal = input.goal ? `用户目标:${input.goal}\n\n` : "";
98
98
  const result = await completeText(
@@ -108,7 +108,7 @@ export async function runAudit(
108
108
  },
109
109
  ],
110
110
  },
111
- { timeoutMs: options.timeoutMs ?? 15_000, signal: options.signal },
111
+ options,
112
112
  );
113
113
  if (!result.ok) return undefined;
114
114
  return parseVerdict(result.text);
@@ -135,7 +135,7 @@ export class Auditor {
135
135
  registry: ModelRegistry,
136
136
  model: AnyModel,
137
137
  input: { goal: string; digest: string },
138
- options: { timeoutMs?: number; signal?: AbortSignal } = {},
138
+ options: CallOptions = {},
139
139
  ): Promise<Verdict | undefined> {
140
140
  if (!this.ready) return undefined;
141
141
  this.inFlight = true;
package/config.ts CHANGED
@@ -2,10 +2,11 @@
2
2
  * config.ts — wingman 配置:读写 <agentDir>/wingman.json(默认 ~/.pi/agent)。
3
3
  *
4
4
  * 三个功能各自独立开关,默认全关(旁路调用都有成本,须用户显式开启);
5
- * 模型形如 "provider/model-id"。未配置模型时各模块自行回退:hint 用当前
6
- * 模型,advisor/heads-up 保持关闭。
5
+ * 模型形如 "provider/model-id",effort 是可选思考强度。未配置模型时各模块
6
+ * 自行回退:hint 用当前模型,advisor/heads-up 保持关闭。
7
7
  */
8
8
 
9
+ import type { ModelThinkingLevel } from "@earendil-works/pi-ai";
9
10
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
10
11
  import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
11
12
  import { dirname, join } from "node:path";
@@ -13,8 +14,14 @@ import { dirname, join } from "node:path";
13
14
  export interface FeatureConfig {
14
15
  enabled: boolean;
15
16
  model?: string;
17
+ effort?: ModelThinkingLevel;
16
18
  }
17
19
 
20
+ export const EFFORTS: readonly ModelThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
21
+
22
+ export const isEffort = (value: unknown): value is ModelThinkingLevel =>
23
+ EFFORTS.includes(value as ModelThinkingLevel);
24
+
18
25
  export interface WingmanConfig {
19
26
  headsup: FeatureConfig;
20
27
  advisor: FeatureConfig;
@@ -37,6 +44,7 @@ function mergeFeature(base: FeatureConfig, override: unknown): FeatureConfig {
37
44
  return {
38
45
  enabled: typeof o.enabled === "boolean" ? o.enabled : base.enabled,
39
46
  model: typeof o.model === "string" ? o.model : base.model,
47
+ effort: isEffort(o.effort) ? o.effort : base.effort,
40
48
  };
41
49
  }
42
50
 
@@ -60,27 +68,29 @@ export function saveConfig(config: WingmanConfig, path: string = configPath()):
60
68
  writeFileSync(path, JSON.stringify(config, null, "\t") + "\n");
61
69
  }
62
70
 
63
- /** 状态行:"hint: on (provider/model)" / "hint: on (current model)" / "hint: off"。 */
71
+ /** 状态行:"hint: on (provider/model · high)" / "hint: on (current model)" / "hint: off"。 */
64
72
  export function featureLine(name: string, feature: FeatureConfig): string {
65
- const state = feature.enabled ? `on (${feature.model ?? "current model"})` : "off";
73
+ const model = feature.model ?? "current model";
74
+ const state = feature.enabled ? `on (${feature.effort ? `${model} · ${feature.effort}` : model})` : "off";
66
75
  return `${name}: ${state}`;
67
76
  }
68
77
 
69
78
  /**
70
- * 应用配置命令参数:"off" 关闭、"on" 重新开启(都保留已设模型),
71
- * "provider/model" 开启并设模型,空参只读。返回状态行;模型校验、
79
+ * 应用配置命令参数:"off" 关闭、"on" 重新开启(都保留已设模型与 effort),
80
+ * "provider/model" 开启并设模型与 effort(档位随模型变,不带即清空),空参只读。返回状态行;模型校验、
72
81
  * saveConfig 与后续副作用(如编辑器同步)由调用方负责。
73
82
  */
74
83
  export function applyFeatureArg(
75
84
  config: WingmanConfig,
76
85
  feature: keyof WingmanConfig,
77
86
  arg: string,
87
+ effort?: ModelThinkingLevel,
78
88
  ): string {
79
89
  const value = arg.trim();
80
90
  if (value === "off" || value === "on") {
81
91
  config[feature] = { ...config[feature], enabled: value === "on" };
82
92
  } else if (value) {
83
- config[feature] = { enabled: true, model: value };
93
+ config[feature] = { enabled: true, model: value, effort };
84
94
  }
85
95
  return featureLine(feature, config[feature]);
86
96
  }
package/headsup.ts CHANGED
@@ -193,7 +193,7 @@ export function installHeadsUp(
193
193
  clearTimer();
194
194
  if (pending.length === 0) return;
195
195
  const config = getConfig().headsup;
196
- // heads-up 必须显式配置模型才激活(开启必经 /headsup <model>);拼错模型也不回退当前模型
196
+ // heads-up 必须显式配置模型才激活(开启必经 /wingman headsup <model>);拼错模型也不回退当前模型
197
197
  if (!config.enabled || !config.model) {
198
198
  debug(`flush 跳过:enabled=${config.enabled} model=${config.model}`);
199
199
  pending = [];
@@ -217,7 +217,7 @@ export function installHeadsUp(
217
217
  ctx.modelRegistry,
218
218
  model,
219
219
  { goal: getGoal(), digest: formatToolEvents(events) },
220
- { timeoutMs: 15_000, signal: shutdown.signal },
220
+ { timeoutMs: 60_000, signal: shutdown.signal, effort: config.effort },
221
221
  );
222
222
  debug(`verdict: ${verdict ? `${verdict.level} ${verdict.summary}` : "(调用失败/跳过)"}`);
223
223
  if (!verdict || (verdict.level !== "concern" && verdict.level !== "blocker")) return;
package/hint.ts CHANGED
@@ -48,7 +48,8 @@ export function installHint(
48
48
  },
49
49
  ],
50
50
  },
51
- { timeoutMs: 10_000, signal: shutdown.signal },
51
+ // hint 要快:高 effort 多半超时,失败即无 hint
52
+ { timeoutMs: 10_000, signal: shutdown.signal, effort: getConfig().hint.effort },
52
53
  );
53
54
  if (!result.ok || gen !== generation) return; // 静默降级 / 已过期
54
55
  editors.current?.setHint(result.text.replace(/\s+/g, " ").trim().slice(0, MAX_HINT_CHARS));
package/index.ts CHANGED
@@ -7,19 +7,22 @@
7
7
  * headsup 任一开启即装);llm.ts 是共享旁路调用层,audit.ts 是 M2/M3
8
8
  * 共享的审计管线。
9
9
  *
10
- * 配置入口对称:/advisor、/hint、/headsup 各接 <provider/model> 开启并设
11
- * 模型(须能在 modelRegistry 解析)、on/off 开关(保留模型)、无参看状态;
12
- * /wingman 总览。用户目标在此统一捕获,跳过 advisor 注入的旁注,供三个
10
+ * 配置集中在 /wingman:无参看总览;/wingman <feature> 接 <provider/model>
11
+ * [effort] 开启并设模型(须能在 modelRegistry 解析,effort 须是该模型支持
12
+ * 的档位)、on/off 开关(保留模型);不带值时 TUI 里弹 pi 的模型与思考
13
+ * 强度选择器,其他模式只看该功能状态。用户目标在此统一捕获,跳过 advisor 注入的旁注,供三个
13
14
  * 功能共用。
14
15
  */
15
16
 
17
+ import { getSupportedThinkingLevels, type ModelThinkingLevel } from "@earendil-works/pi-ai";
16
18
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
17
19
  import { installAdvisor, isAdvisorNote } from "./advisor.js";
18
- import { applyFeatureArg, featureLine, loadConfig, saveConfig, type WingmanConfig } from "./config.js";
20
+ import { applyFeatureArg, featureLine, isEffort, loadConfig, saveConfig, type WingmanConfig } from "./config.js";
19
21
  import { EditorManager } from "./editor.js";
20
22
  import { installHeadsUp } from "./headsup.js";
21
23
  import { installHint } from "./hint.js";
22
24
  import { resolveModel, type ModelRegistry } from "./llm.js";
25
+ import { pickModel, type Picked } from "./picker.js";
23
26
 
24
27
  export default function (pi: ExtensionAPI) {
25
28
  let config: WingmanConfig = loadConfig();
@@ -30,50 +33,93 @@ export default function (pi: ExtensionAPI) {
30
33
  const getConfig = () => config;
31
34
  const getGoal = () => goal;
32
35
 
33
- pi.registerCommand("wingman", {
34
- description: "Show wingman status (headsup / advisor / hint)",
35
- handler: async (_args, ctx) => {
36
- ctx.ui.notify(
37
- [
38
- "wingman",
39
- featureLine("headsup", config.headsup),
40
- featureLine("advisor", config.advisor),
41
- featureLine("hint", config.hint),
42
- ].join(" · "),
43
- "info",
44
- );
45
- },
46
- });
36
+ const features = ["advisor", "hint", "headsup"] as const;
37
+ type Feature = (typeof features)[number];
38
+ const isFeature = (name: string | undefined): name is Feature => features.includes(name as Feature);
47
39
 
48
- for (const feature of ["advisor", "hint", "headsup"] as const) {
49
- pi.registerCommand(feature, {
50
- description: `Configure wingman ${feature}: /${feature} <provider/model> | on | off`,
51
- getArgumentCompletions: (prefix) => {
52
- const models = registry?.getAvailable().map((m) => `${m.provider}/${m.id}`) ?? [];
53
- const items = ["on", "off", ...models]
54
- .filter((value) => value.startsWith(prefix))
55
- .map((value) => ({ value, label: value }));
40
+ const usage = "/wingman <advisor|hint|headsup> [<provider/model> [effort] | on | off]";
41
+
42
+ pi.registerCommand("wingman", {
43
+ description: `Wingman status, or configure: ${usage}`,
44
+ getArgumentCompletions: (prefix) => {
45
+ const [feature, value, effort, ...rest] = prefix.trimStart().split(/\s+/);
46
+ if (rest.length > 0) return null;
47
+ if (value === undefined) {
48
+ const items = features
49
+ .filter((name) => name.startsWith(feature ?? ""))
50
+ .map((name) => ({ value: `${name} `, label: name }));
56
51
  return items.length > 0 ? items : null;
57
- },
58
- handler: async (args, ctx) => {
59
- const value = args.trim();
60
- if (value && value !== "on" && value !== "off" && !resolveModel(ctx.modelRegistry, value, undefined)) {
61
- ctx.ui.notify(`wingman · 找不到模型 ${value}(格式 provider/model-id)`, "error");
52
+ }
53
+ if (!isFeature(feature)) return null;
54
+ if (effort !== undefined) {
55
+ const model = registry && resolveModel(registry, value, undefined);
56
+ if (!model) return null;
57
+ const items = getSupportedThinkingLevels(model)
58
+ .filter((level) => level.startsWith(effort))
59
+ .map((level) => ({ value: `${feature} ${value} ${level}`, label: level }));
60
+ return items.length > 0 ? items : null;
61
+ }
62
+ const models = registry?.getAvailable().map((m) => `${m.provider}/${m.id}`) ?? [];
63
+ const items = ["on", "off", ...models]
64
+ .filter((option) => option.startsWith(value))
65
+ .map((option) => ({ value: `${feature} ${option}`, label: option }));
66
+ return items.length > 0 ? items : null;
67
+ },
68
+ handler: async (args, ctx) => {
69
+ const [feature, arg = "", effortArg, ...extra] = args.trim().split(/\s+/).filter(Boolean);
70
+ if (feature === undefined) {
71
+ ctx.ui.notify(
72
+ ["wingman", ...features.map((name) => featureLine(name, config[name]))].join(" · "),
73
+ "info",
74
+ );
75
+ return;
76
+ }
77
+ const isSwitch = arg === "on" || arg === "off";
78
+ if (!isFeature(feature) || extra.length > 0 || (effortArg !== undefined && isSwitch)) {
79
+ ctx.ui.notify(`wingman · 用法 ${usage}`, "error");
80
+ return;
81
+ }
82
+ let value = arg;
83
+ let effort: ModelThinkingLevel | undefined;
84
+ if (!value && ctx.mode === "tui") {
85
+ let picked: Picked | undefined;
86
+ try {
87
+ picked = await pickModel(ctx, config[feature]);
88
+ } catch (error) {
89
+ const reason = error instanceof Error ? error.message : String(error);
90
+ ctx.ui.notify(`wingman · 选择器不可用(${reason}),改用 /wingman ${feature} <provider/model> [effort]`, "error");
62
91
  return;
63
92
  }
64
- if (value === "on" && feature !== "hint" && !config[feature].model) {
65
- ctx.ui.notify(`wingman · ${feature} 需要模型,先 /${feature} <provider/model>`, "error");
93
+ if (!picked) return;
94
+ value = `${picked.model.provider}/${picked.model.id}`;
95
+ effort = picked.effort;
96
+ } else if (value && !isSwitch) {
97
+ const model = resolveModel(ctx.modelRegistry, value, undefined);
98
+ if (!model) {
99
+ ctx.ui.notify(`wingman · 找不到模型 ${value}(格式 provider/model-id)`, "error");
66
100
  return;
67
101
  }
68
- const line = applyFeatureArg(config, feature, args);
69
- if (args.trim()) {
70
- saveConfig(config);
71
- editors.sync(ctx, config);
102
+ if (effortArg !== undefined) {
103
+ const levels = getSupportedThinkingLevels(model);
104
+ if (!isEffort(effortArg) || !levels.includes(effortArg)) {
105
+ ctx.ui.notify(`wingman · ${value} 支持的 effort:${levels.join(" / ")}`, "error");
106
+ return;
107
+ }
108
+ effort = effortArg;
72
109
  }
73
- ctx.ui.notify(`wingman · ${line}`, "info");
74
- },
75
- });
76
- }
110
+ }
111
+ if (value === "on" && feature !== "hint" && !config[feature].model) {
112
+ ctx.ui.notify(`wingman · ${feature} 需要模型,先 /wingman ${feature} <provider/model>`, "error");
113
+ return;
114
+ }
115
+ const line = applyFeatureArg(config, feature, value, effort);
116
+ if (value) {
117
+ saveConfig(config);
118
+ editors.sync(ctx, config);
119
+ }
120
+ ctx.ui.notify(`wingman · ${line}`, "info");
121
+ },
122
+ });
77
123
 
78
124
  pi.on("session_start", (_event, ctx) => {
79
125
  config = loadConfig();
package/llm.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * llm.ts — 旁路模型调用封装。
3
3
  *
4
- * 经 ctx.modelRegistry.complete() 调 provider 模型:
4
+ * 经 ctx.modelRegistry.streamSimple() 调 provider 模型(provider 中立的选项里
5
+ * 才有 reasoning,complete() 没有):
5
6
  * - resolveModel(registry, "provider/model-id") 解析配置串,未配置/找不到时
6
7
  * 回退当前模型;
7
8
  * - completeText() 统一超时与 AbortController(idle 时 ctx.signal 不存在,
@@ -11,14 +12,18 @@
11
12
  * 失败一律返回 { ok: false, reason },不抛异常进 pi 事件循环。
12
13
  */
13
14
 
15
+ import { clampThinkingLevel, type ModelThinkingLevel } from "@earendil-works/pi-ai";
14
16
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
15
17
 
16
18
  export type ModelRegistry = ExtensionContext["modelRegistry"];
17
19
  export type AnyModel = NonNullable<ExtensionContext["model"]>;
18
- export type LlmContext = Parameters<ModelRegistry["complete"]>[1];
20
+ export type LlmContext = Parameters<ModelRegistry["streamSimple"]>[1];
19
21
  /** pi-ai 的 Message:按 role 可辨识的联合(system/user/assistant/toolResult)。 */
20
22
  export type Message = LlmContext["messages"][number];
21
23
 
24
+ /** effort 是思考强度,按模型支持的档位校正;未设或 "off" 不开推理。 */
25
+ export type CallOptions = { timeoutMs?: number; signal?: AbortSignal; effort?: ModelThinkingLevel };
26
+
22
27
  export type CompleteResult = { ok: true; text: string } | { ok: false; reason: string };
23
28
 
24
29
  /** 拼接内容块里的文本块。 */
@@ -59,14 +64,17 @@ export async function completeText(
59
64
  registry: ModelRegistry,
60
65
  model: AnyModel,
61
66
  context: LlmContext,
62
- options: { timeoutMs?: number; signal?: AbortSignal } = {},
67
+ options: CallOptions = {},
63
68
  ): Promise<CompleteResult> {
64
69
  const controller = new AbortController();
65
70
  const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? 15_000);
66
71
  const onOuterAbort = () => controller.abort();
67
72
  options.signal?.addEventListener("abort", onOuterAbort, { once: true });
68
73
  try {
69
- const response = await registry.complete(model, context, { signal: controller.signal });
74
+ const effort = options.effort && clampThinkingLevel(model, options.effort);
75
+ const response = await registry
76
+ .streamSimple(model, context, { signal: controller.signal, reasoning: effort === "off" ? undefined : effort })
77
+ .result();
70
78
  if (response.stopReason !== "stop") {
71
79
  return { ok: false, reason: `stopReason=${response.stopReason}` };
72
80
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@master.g/pi-wingman",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "description": "Wingman for pi — heads-up audit while the agent works, a designated advisor model, and next-prompt hints (Tab/Enter to accept)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -38,6 +38,7 @@
38
38
  "typecheck": "tsc --noEmit"
39
39
  },
40
40
  "peerDependencies": {
41
+ "@earendil-works/pi-ai": "*",
41
42
  "@earendil-works/pi-coding-agent": "*",
42
43
  "@earendil-works/pi-tui": "*"
43
44
  },
package/picker.ts ADDED
@@ -0,0 +1,42 @@
1
+ /**
2
+ * picker.ts — `/wingman <feature>` 不带值时的交互选择:依次复用 pi 的
3
+ * /model 与 /thinking 选择器选模型和 effort。仅 TUI 可用。
4
+ */
5
+
6
+ import { clampThinkingLevel, getSupportedThinkingLevels, type ModelThinkingLevel } from "@earendil-works/pi-ai";
7
+ import {
8
+ ModelSelectorComponent,
9
+ ThinkingSelectorComponent,
10
+ type ExtensionContext,
11
+ type ModelRuntime,
12
+ } from "@earendil-works/pi-coding-agent";
13
+ import type { FeatureConfig } from "./config.js";
14
+ import { resolveModel, type AnyModel, type ModelRegistry } from "./llm.js";
15
+
16
+ export type Picked = { model: AnyModel; effort?: ModelThinkingLevel };
17
+
18
+ /** 选模型再选 effort;选模型时取消返回 undefined,选 effort 时取消则不设 effort。 */
19
+ export async function pickModel(ctx: ExtensionContext, current: FeatureConfig): Promise<Picked | undefined> {
20
+ const registry = ctx.modelRegistry;
21
+ // ponytail: 组件要 ModelRuntime,扩展只拿得到 ModelRegistry;组件只调这 4 个方法,
22
+ // registry 内部也是原样转发。pi 让组件多调方法时会抛错,调用方兜底回退手输。
23
+ const runtime = {
24
+ getAvailableSnapshot: () => registry.getAvailable(),
25
+ getModel: (provider: string, id: string) => registry.find(provider, id),
26
+ getError: () => registry.getError(),
27
+ refresh: (options: Parameters<ModelRegistry["refresh"]>[0]) => registry.refresh(options),
28
+ } as unknown as ModelRuntime;
29
+ const currentModel = resolveModel(registry, current.model, undefined);
30
+ const model = await ctx.ui.custom<AnyModel | undefined>(
31
+ (tui, _theme, _kb, done) => new ModelSelectorComponent(tui, currentModel, runtime, [], done, () => done(undefined)),
32
+ );
33
+ if (!model) return undefined;
34
+
35
+ const levels = getSupportedThinkingLevels(model);
36
+ if (levels.length <= 1) return { model };
37
+ const effort = await ctx.ui.custom<ModelThinkingLevel | undefined>(
38
+ (_tui, _theme, _kb, done) =>
39
+ new ThinkingSelectorComponent(clampThinkingLevel(model, current.effort ?? "off"), levels, done, () => done(undefined)),
40
+ );
41
+ return { model, effort };
42
+ }