@hadooppei/hwcode 0.1.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.
@@ -0,0 +1,450 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
+ import type {
6
+ ApiKeyCredential,
7
+ Model,
8
+ Provider,
9
+ ProviderAuthInteraction,
10
+ RefreshModelsContext,
11
+ } from "@earendil-works/pi-ai";
12
+ import { stream, streamSimple } from "@earendil-works/pi-ai/compat";
13
+
14
+ type ModelInput = "text" | "image";
15
+ type OpenAIModel = Model<"openai-completions">;
16
+
17
+ interface ConfiguredModel {
18
+ id: string;
19
+ name?: string;
20
+ reasoning?: boolean;
21
+ input?: ModelInput[];
22
+ contextWindow?: number;
23
+ maxTokens?: number;
24
+ }
25
+
26
+ interface ModelDefaults {
27
+ reasoning?: boolean;
28
+ input?: ModelInput[];
29
+ contextWindow?: number;
30
+ maxTokens?: number;
31
+ }
32
+
33
+ interface LoginConfig {
34
+ enabled: true;
35
+ promptBaseUrl?: boolean;
36
+ promptApiKey?: boolean;
37
+ apiKeyRequired?: boolean;
38
+ catalogPath?: string;
39
+ timeoutMs?: number;
40
+ }
41
+
42
+ interface ModelProviderConfig {
43
+ id: string;
44
+ name?: string;
45
+ baseUrl?: string;
46
+ baseUrlEnv?: string;
47
+ apiKeyEnv?: string;
48
+ login?: LoginConfig;
49
+ modelDefaults?: ModelDefaults;
50
+ models?: ConfiguredModel[];
51
+ }
52
+
53
+ interface ModelProvidersConfig {
54
+ providers: ModelProviderConfig[];
55
+ }
56
+
57
+ interface RemoteModel {
58
+ id: string;
59
+ name?: string;
60
+ input?: ModelInput[];
61
+ }
62
+
63
+ const BUNDLED_CONFIG_PATH = join(dirname(fileURLToPath(import.meta.url)), "..", "model-providers.json");
64
+ const CREDENTIAL_BASE_URL = "BASE_URL";
65
+ const DEFAULT_CONTEXT_WINDOW = 32768;
66
+ const DEFAULT_MAX_TOKENS = 8192;
67
+ const DEFAULT_CATALOG_PATH = "models";
68
+ const DEFAULT_TIMEOUT_MS = 15000;
69
+
70
+ function isRecord(value: unknown): value is Record<string, unknown> {
71
+ return typeof value === "object" && value !== null;
72
+ }
73
+
74
+ function isPositiveNumber(value: unknown): value is number {
75
+ return typeof value === "number" && Number.isFinite(value) && value > 0;
76
+ }
77
+
78
+ function normalizeBaseUrl(value: string): string {
79
+ const url = new URL(value.trim());
80
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
81
+ throw new Error("Provider base URL must use http or https");
82
+ }
83
+ url.hash = "";
84
+ url.search = "";
85
+ return url.toString().replace(/\/$/u, "");
86
+ }
87
+
88
+ function validateModel(providerId: string, model: ConfiguredModel): void {
89
+ if (!model.id?.trim()) {
90
+ throw new Error(`Provider "${providerId}" contains a model without an id`);
91
+ }
92
+ if (model.input?.some((input) => input !== "text" && input !== "image")) {
93
+ throw new Error(`Model "${model.id}" has an unsupported input type`);
94
+ }
95
+ if (model.contextWindow !== undefined && !isPositiveNumber(model.contextWindow)) {
96
+ throw new Error(`Model "${model.id}" has an invalid contextWindow`);
97
+ }
98
+ if (model.maxTokens !== undefined && !isPositiveNumber(model.maxTokens)) {
99
+ throw new Error(`Model "${model.id}" has an invalid maxTokens`);
100
+ }
101
+ }
102
+
103
+ function loadConfig(): ModelProvidersConfig {
104
+ const projectConfigPath = resolve(process.cwd(), ".pi/model-providers.json");
105
+ const profileConfigPath = process.env.HWCODE_PROFILE_DIR
106
+ ? join(process.env.HWCODE_PROFILE_DIR, "model-providers.json")
107
+ : BUNDLED_CONFIG_PATH;
108
+ const configPath = existsSync(projectConfigPath) ? projectConfigPath : profileConfigPath;
109
+ const config = JSON.parse(readFileSync(configPath, "utf8")) as ModelProvidersConfig;
110
+ if (!Array.isArray(config.providers) || config.providers.length === 0) {
111
+ throw new Error(`${configPath} must contain a non-empty providers array`);
112
+ }
113
+
114
+ const providerIds = new Set<string>();
115
+ for (const provider of config.providers) {
116
+ if (!provider.id?.trim()) {
117
+ throw new Error(`${configPath} contains a provider without an id`);
118
+ }
119
+ if (providerIds.has(provider.id)) {
120
+ throw new Error(`${configPath} contains duplicate provider id "${provider.id}"`);
121
+ }
122
+ providerIds.add(provider.id);
123
+
124
+ if (provider.baseUrl) normalizeBaseUrl(provider.baseUrl);
125
+ if (!provider.login?.enabled && !provider.baseUrl?.trim()) {
126
+ throw new Error(`Static provider "${provider.id}" requires baseUrl`);
127
+ }
128
+ if (!provider.login?.enabled && (!Array.isArray(provider.models) || provider.models.length === 0)) {
129
+ throw new Error(`Static provider "${provider.id}" must contain at least one model`);
130
+ }
131
+ if (provider.login?.enabled && provider.login.promptBaseUrl === false && !provider.baseUrl?.trim()) {
132
+ throw new Error(`Login provider "${provider.id}" requires baseUrl when promptBaseUrl is false`);
133
+ }
134
+ if (provider.login?.catalogPath !== undefined && !provider.login.catalogPath.trim()) {
135
+ throw new Error(`Login provider "${provider.id}" has an empty catalogPath`);
136
+ }
137
+ if (provider.login?.timeoutMs !== undefined && !isPositiveNumber(provider.login.timeoutMs)) {
138
+ throw new Error(`Login provider "${provider.id}" has an invalid timeoutMs`);
139
+ }
140
+
141
+ const modelIds = new Set<string>();
142
+ for (const model of provider.models ?? []) {
143
+ validateModel(provider.id, model);
144
+ if (modelIds.has(model.id)) {
145
+ throw new Error(`Provider "${provider.id}" contains duplicate model id "${model.id}"`);
146
+ }
147
+ modelIds.add(model.id);
148
+ }
149
+ }
150
+
151
+ return config;
152
+ }
153
+
154
+ function compatibility() {
155
+ return {
156
+ supportsDeveloperRole: false,
157
+ supportsReasoningEffort: false,
158
+ supportsStore: false,
159
+ supportsStrictMode: false,
160
+ maxTokensField: "max_tokens" as const,
161
+ };
162
+ }
163
+
164
+ function toPiModel(
165
+ provider: ModelProviderConfig,
166
+ baseUrl: string,
167
+ remote: RemoteModel,
168
+ ): OpenAIModel {
169
+ const override = provider.models?.find((model) => model.id === remote.id);
170
+ const defaults = provider.modelDefaults;
171
+ const contextWindow = override?.contextWindow ?? defaults?.contextWindow ?? DEFAULT_CONTEXT_WINDOW;
172
+ const maxTokens = override?.maxTokens ?? defaults?.maxTokens ?? DEFAULT_MAX_TOKENS;
173
+
174
+ return {
175
+ id: remote.id,
176
+ name: override?.name ?? remote.name ?? remote.id,
177
+ api: "openai-completions",
178
+ provider: provider.id,
179
+ baseUrl,
180
+ reasoning: override?.reasoning ?? defaults?.reasoning ?? false,
181
+ input: override?.input ?? remote.input ?? defaults?.input ?? ["text"],
182
+ contextWindow,
183
+ maxTokens: Math.min(maxTokens, contextWindow),
184
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
185
+ compat: compatibility(),
186
+ };
187
+ }
188
+
189
+ function remoteInput(model: Record<string, unknown>): ModelInput[] | undefined {
190
+ const architecture = isRecord(model.architecture) ? model.architecture : undefined;
191
+ const raw = model.input_modalities ?? architecture?.input_modalities;
192
+ if (!Array.isArray(raw)) return undefined;
193
+ const values = raw.filter((value): value is string => typeof value === "string");
194
+ const input: ModelInput[] = ["text"];
195
+ if (values.some((value) => value === "image" || value === "vision")) input.push("image");
196
+ return input;
197
+ }
198
+
199
+ function parseRemoteModels(payload: unknown): RemoteModel[] {
200
+ const entries = Array.isArray(payload)
201
+ ? payload
202
+ : isRecord(payload) && Array.isArray(payload.data)
203
+ ? payload.data
204
+ : undefined;
205
+ if (!entries) throw new Error("Model endpoint returned an invalid catalog");
206
+
207
+ const models: RemoteModel[] = [];
208
+ const ids = new Set<string>();
209
+ for (const entry of entries) {
210
+ if (!isRecord(entry) || typeof entry.id !== "string" || !entry.id.trim()) continue;
211
+ const id = entry.id.trim();
212
+ if (ids.has(id)) continue;
213
+ ids.add(id);
214
+ models.push({
215
+ id,
216
+ name: typeof entry.name === "string" && entry.name.trim() ? entry.name.trim() : undefined,
217
+ input: remoteInput(entry),
218
+ });
219
+ }
220
+
221
+ if (models.length === 0) throw new Error("Model endpoint returned no usable models");
222
+ return models;
223
+ }
224
+
225
+ function responseError(payload: unknown): string | undefined {
226
+ if (!isRecord(payload)) return undefined;
227
+ if (typeof payload.message === "string") return payload.message;
228
+ return isRecord(payload.error) && typeof payload.error.message === "string"
229
+ ? payload.error.message
230
+ : undefined;
231
+ }
232
+
233
+ async function discoverModels(
234
+ provider: ModelProviderConfig,
235
+ baseUrl: string,
236
+ apiKey: string | undefined,
237
+ signal: AbortSignal,
238
+ ): Promise<OpenAIModel[]> {
239
+ const login = provider.login;
240
+ if (!login) throw new Error(`Provider "${provider.id}" has no login configuration`);
241
+
242
+ const catalogUrl = new URL(login.catalogPath ?? DEFAULT_CATALOG_PATH, `${baseUrl}/`);
243
+ const headers = new Headers({ Accept: "application/json" });
244
+ if (apiKey) headers.set("Authorization", `Bearer ${apiKey}`);
245
+ const timeoutSignal = AbortSignal.timeout(login.timeoutMs ?? DEFAULT_TIMEOUT_MS);
246
+ const response = await fetch(catalogUrl, {
247
+ headers,
248
+ signal: AbortSignal.any([signal, timeoutSignal]),
249
+ });
250
+
251
+ let payload: unknown;
252
+ try {
253
+ payload = await response.json();
254
+ } catch {
255
+ payload = undefined;
256
+ }
257
+ if (!response.ok) {
258
+ const detail = responseError(payload);
259
+ throw new Error(`Model discovery failed with HTTP ${response.status}${detail ? `: ${detail}` : ""}`);
260
+ }
261
+
262
+ return parseRemoteModels(payload).map((model) => toPiModel(provider, baseUrl, model));
263
+ }
264
+
265
+ async function environmentValue(name: string | undefined): Promise<string | undefined> {
266
+ if (!name) return undefined;
267
+ const value = process.env[name]?.trim();
268
+ return value || undefined;
269
+ }
270
+
271
+ function storedBaseUrl(credential: ApiKeyCredential | undefined): string | undefined {
272
+ const value = credential?.env?.[CREDENTIAL_BASE_URL];
273
+ return typeof value === "string" && value.trim() ? normalizeBaseUrl(value) : undefined;
274
+ }
275
+
276
+ async function configuredBaseUrl(
277
+ provider: ModelProviderConfig,
278
+ credential: ApiKeyCredential | undefined,
279
+ readEnv: (name: string) => Promise<string | undefined>,
280
+ ): Promise<string | undefined> {
281
+ const fromCredential = storedBaseUrl(credential);
282
+ if (fromCredential) return fromCredential;
283
+ if (provider.baseUrlEnv) {
284
+ const fromEnvironment = (await readEnv(provider.baseUrlEnv))?.trim();
285
+ if (fromEnvironment) return normalizeBaseUrl(fromEnvironment);
286
+ }
287
+ return provider.baseUrl ? normalizeBaseUrl(provider.baseUrl) : undefined;
288
+ }
289
+
290
+ function registerStaticProvider(pi: ExtensionAPI, config: ModelProviderConfig): void {
291
+ const baseUrl = normalizeBaseUrl(config.baseUrl!);
292
+ const apiKey = config.apiKeyEnv
293
+ ? process.env[config.apiKeyEnv]?.trim() || "local"
294
+ : "local";
295
+
296
+ pi.registerProvider(config.id, {
297
+ name: config.name ?? config.id,
298
+ baseUrl,
299
+ api: "openai-completions",
300
+ apiKey,
301
+ compat: compatibility(),
302
+ models: (config.models ?? []).map((model) => ({
303
+ id: model.id,
304
+ name: model.name ?? model.id,
305
+ reasoning: model.reasoning ?? config.modelDefaults?.reasoning ?? false,
306
+ input: model.input ?? config.modelDefaults?.input ?? ["text"],
307
+ contextWindow: model.contextWindow ?? config.modelDefaults?.contextWindow ?? DEFAULT_CONTEXT_WINDOW,
308
+ maxTokens: model.maxTokens ?? config.modelDefaults?.maxTokens ?? DEFAULT_MAX_TOKENS,
309
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
310
+ })),
311
+ });
312
+ }
313
+
314
+ async function loginAndDiscover(
315
+ config: ModelProviderConfig,
316
+ interaction: ProviderAuthInteraction,
317
+ ): Promise<{ credential: ApiKeyCredential; models: OpenAIModel[] }> {
318
+ const login = config.login!;
319
+ try {
320
+ let baseUrl = config.baseUrl ? normalizeBaseUrl(config.baseUrl) : undefined;
321
+ if (login.promptBaseUrl !== false) {
322
+ const entered = await interaction.prompt({
323
+ type: "text",
324
+ message: `${config.name ?? config.id} OpenAI-compatible base URL`,
325
+ placeholder: baseUrl,
326
+ });
327
+ const selectedBaseUrl = entered.trim() || baseUrl;
328
+ if (!selectedBaseUrl) throw new Error(`Provider "${config.id}" requires a base URL`);
329
+ baseUrl = normalizeBaseUrl(selectedBaseUrl);
330
+ }
331
+ if (!baseUrl) throw new Error(`Provider "${config.id}" requires a base URL`);
332
+
333
+ let enteredApiKey = "";
334
+ if (login.promptApiKey !== false) {
335
+ enteredApiKey = (await interaction.prompt({
336
+ type: "secret",
337
+ message: `API key${login.apiKeyRequired ? "" : " (optional)"}`,
338
+ })).trim();
339
+ }
340
+ const apiKey = enteredApiKey || await environmentValue(config.apiKeyEnv);
341
+ if (login.apiKeyRequired && !apiKey) {
342
+ throw new Error(`Provider "${config.id}" requires an API key`);
343
+ }
344
+
345
+ interaction.notify({ type: "progress", message: `Discovering models from ${baseUrl}` });
346
+ const models = await discoverModels(config, baseUrl, apiKey, interaction.signal);
347
+ interaction.notify({ type: "info", message: `Found ${models.length} model${models.length === 1 ? "" : "s"}.` });
348
+
349
+ return {
350
+ credential: {
351
+ type: "api_key",
352
+ key: enteredApiKey || undefined,
353
+ env: { [CREDENTIAL_BASE_URL]: baseUrl },
354
+ },
355
+ models,
356
+ };
357
+ } catch (error) {
358
+ if (interaction.signal.aborted || (error instanceof Error && error.name === "AbortError")) {
359
+ throw new Error("Login cancelled");
360
+ }
361
+ throw error;
362
+ }
363
+ }
364
+
365
+ function createLoginProvider(config: ModelProviderConfig): Provider<"openai-completions"> {
366
+ const login = config.login!;
367
+ const displayName = config.name ?? config.id;
368
+ let models: OpenAIModel[] = config.baseUrl
369
+ ? (config.models ?? []).map((model) => toPiModel(
370
+ config,
371
+ normalizeBaseUrl(config.baseUrl!),
372
+ { id: model.id, name: model.name, input: model.input },
373
+ ))
374
+ : [];
375
+
376
+ const ambientApiKey = () => environmentValue(config.apiKeyEnv);
377
+ const ambientBaseUrl = () => environmentValue(config.baseUrlEnv);
378
+ const hasAmbientConfiguration = async () => Boolean(await ambientApiKey() || await ambientBaseUrl());
379
+
380
+ return {
381
+ id: config.id,
382
+ name: displayName,
383
+ baseUrl: config.baseUrl ? normalizeBaseUrl(config.baseUrl) : undefined,
384
+ auth: {
385
+ apiKey: {
386
+ name: `${displayName} credentials`,
387
+ login: async (interaction: ProviderAuthInteraction) => {
388
+ const result = await loginAndDiscover(config, interaction);
389
+ models = result.models;
390
+ return result.credential;
391
+ },
392
+ check: async ({ ctx, credential, signal }) => {
393
+ signal.throwIfAborted();
394
+ if (!credential && !await hasAmbientConfiguration()) return undefined;
395
+ const baseUrl = await configuredBaseUrl(config, credential, (name) => ctx.env(name));
396
+ const apiKey = credential?.key ?? (config.apiKeyEnv ? await ctx.env(config.apiKeyEnv) : undefined);
397
+ if (!baseUrl || (login.apiKeyRequired && !apiKey)) return undefined;
398
+ return { type: "api_key", source: credential ? "stored credential" : config.apiKeyEnv ?? config.baseUrlEnv };
399
+ },
400
+ resolve: async ({ ctx, credential, signal }) => {
401
+ signal.throwIfAborted();
402
+ if (!credential && !await hasAmbientConfiguration()) return undefined;
403
+ const baseUrl = await configuredBaseUrl(config, credential, (name) => ctx.env(name));
404
+ const apiKey = credential?.key ?? (config.apiKeyEnv ? await ctx.env(config.apiKeyEnv) : undefined);
405
+ if (!baseUrl || (login.apiKeyRequired && !apiKey)) return undefined;
406
+ return {
407
+ auth: { apiKey: apiKey || "local", baseUrl },
408
+ env: { ...credential?.env, [CREDENTIAL_BASE_URL]: baseUrl },
409
+ source: credential ? "stored credential" : config.apiKeyEnv ?? config.baseUrlEnv,
410
+ };
411
+ },
412
+ },
413
+ },
414
+ getModels: () => models,
415
+ refreshModels: async (context: RefreshModelsContext) => {
416
+ if (context.stored) {
417
+ const restored = context.stored.models.filter(
418
+ (model): model is OpenAIModel => model.provider === config.id && model.api === "openai-completions",
419
+ );
420
+ if (!await context.publish({ update: () => { models = restored; } })) return;
421
+ }
422
+ if (!context.allowNetwork || context.signal.aborted) return;
423
+
424
+ const credential = context.credential?.type === "api_key" ? context.credential : undefined;
425
+ if (!credential && !await hasAmbientConfiguration()) return;
426
+ const environmentBaseUrl = await ambientBaseUrl();
427
+ const baseUrl = storedBaseUrl(credential)
428
+ ?? (environmentBaseUrl ? normalizeBaseUrl(environmentBaseUrl) : undefined)
429
+ ?? (config.baseUrl ? normalizeBaseUrl(config.baseUrl) : undefined);
430
+ const apiKey = credential?.key ?? await ambientApiKey();
431
+ if (!baseUrl || (login.apiKeyRequired && !apiKey)) return;
432
+
433
+ const refreshed = await discoverModels(config, baseUrl, apiKey, context.signal);
434
+ await context.publish({
435
+ persist: { models: refreshed, checkedAt: Date.now() },
436
+ update: () => { models = refreshed; },
437
+ });
438
+ },
439
+ stream: (model, context, options) => stream(model, context, options),
440
+ streamSimple: (model, context, options) => streamSimple(model, context, options),
441
+ };
442
+ }
443
+
444
+ export default function modelProvidersExtension(pi: ExtensionAPI) {
445
+ const config = loadConfig();
446
+ for (const provider of config.providers) {
447
+ if (provider.login?.enabled) pi.registerProvider(createLoginProvider(provider));
448
+ else registerStaticProvider(pi, provider);
449
+ }
450
+ }
@@ -0,0 +1,168 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import {
5
+ CONFIG_DIR_NAME,
6
+ type ExtensionAPI,
7
+ type ExtensionContext,
8
+ type ThemeColor,
9
+ } from "@earendil-works/pi-coding-agent";
10
+ import { isThemeColor, renderPixelText } from "../lib/pixel-font.ts";
11
+ import { shouldDismissWelcomeOnSubmit } from "../lib/welcome-input.ts";
12
+
13
+ interface WelcomeConfig {
14
+ title: string;
15
+ logo: string;
16
+ characterColors: ThemeColor[];
17
+ subtitle: string;
18
+ subtitleColor: ThemeColor;
19
+ reservedRows: number;
20
+ }
21
+
22
+ const DEFAULT_CONFIG: WelcomeConfig = {
23
+ title: "HWCode · Pi",
24
+ logo: "HWCODE",
25
+ characterColors: ["accent"],
26
+ subtitle: "Local AI Coding Workspace",
27
+ subtitleColor: "muted",
28
+ reservedRows: 8,
29
+ };
30
+
31
+ function loadConfig(cwd: string): WelcomeConfig {
32
+ const projectPath = join(cwd, CONFIG_DIR_NAME, "welcome.json");
33
+ const profilePath = process.env.HWCODE_PROFILE_DIR
34
+ ? join(process.env.HWCODE_PROFILE_DIR, "welcome.json")
35
+ : join(dirname(fileURLToPath(import.meta.url)), "..", "welcome.json");
36
+ const path = existsSync(projectPath) ? projectPath : profilePath;
37
+ const raw = JSON.parse(readFileSync(path, "utf8")) as Partial<WelcomeConfig>;
38
+ const logo = raw.logo?.trim().toUpperCase() ?? DEFAULT_CONFIG.logo;
39
+
40
+ if (!/^[A-Z]{1,6}$/.test(logo)) {
41
+ throw new Error("welcome.logo must contain 1-6 English letters");
42
+ }
43
+
44
+ const characterColors = raw.characterColors ?? DEFAULT_CONFIG.characterColors;
45
+ if (characterColors.length === 0 || !characterColors.every(isThemeColor)) {
46
+ throw new Error("welcome.characterColors contains an unsupported Pi theme color");
47
+ }
48
+ if (raw.subtitleColor !== undefined && !isThemeColor(raw.subtitleColor)) {
49
+ throw new Error("welcome.subtitleColor is not a supported Pi theme color");
50
+ }
51
+
52
+ return {
53
+ title: raw.title?.trim() || DEFAULT_CONFIG.title,
54
+ logo,
55
+ characterColors,
56
+ subtitle: raw.subtitle?.trim() ?? DEFAULT_CONFIG.subtitle,
57
+ subtitleColor: raw.subtitleColor ?? DEFAULT_CONFIG.subtitleColor,
58
+ reservedRows:
59
+ typeof raw.reservedRows === "number" && raw.reservedRows >= 5
60
+ ? Math.floor(raw.reservedRows)
61
+ : DEFAULT_CONFIG.reservedRows,
62
+ };
63
+ }
64
+
65
+ function centerText(text: string, width: number): string {
66
+ return `${" ".repeat(Math.max(0, Math.floor((width - text.length) / 2)))}${text}`;
67
+ }
68
+
69
+ export default function welcomeExtension(pi: ExtensionAPI) {
70
+ let welcomeVisible = false;
71
+ let clearTerminal: (() => void) | undefined;
72
+
73
+ function showWelcome(ctx: ExtensionContext) {
74
+ if (ctx.mode !== "tui") return;
75
+
76
+ let config: WelcomeConfig;
77
+ try {
78
+ config = loadConfig(ctx.cwd);
79
+ } catch (error) {
80
+ config = DEFAULT_CONFIG;
81
+ ctx.ui.notify(`Welcome config error: ${(error as Error).message}`, "error");
82
+ }
83
+
84
+ const model = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "未选择";
85
+ welcomeVisible = true;
86
+ ctx.ui.setTitle(config.title);
87
+
88
+ ctx.ui.setHeader((tui, theme) => {
89
+ clearTerminal = () => {
90
+ tui.terminal.clearScreen();
91
+ tui.requestRender(true);
92
+ };
93
+
94
+ return {
95
+ render(width) {
96
+ const availableHeight = Math.max(1, tui.terminal.rows - config.reservedRows);
97
+ const subtitleHeight = config.subtitle ? 2 : 0;
98
+ const logo = renderPixelText(
99
+ config.logo,
100
+ config.characterColors,
101
+ theme,
102
+ width,
103
+ Math.max(1, availableHeight - subtitleHeight),
104
+ );
105
+ const groupHeight = logo.height + subtitleHeight;
106
+ const topPadding = Math.max(0, Math.floor((availableHeight - groupHeight) / 2));
107
+ const bottomPadding = Math.max(0, availableHeight - topPadding - groupHeight);
108
+ const lines = [...Array<string>(topPadding).fill(""), ...logo.lines];
109
+
110
+ if (config.subtitle) {
111
+ lines.push("", theme.fg(config.subtitleColor, centerText(config.subtitle, width)));
112
+ }
113
+
114
+ lines.push(...Array<string>(bottomPadding).fill(""));
115
+ return lines;
116
+ },
117
+ invalidate() {},
118
+ };
119
+ });
120
+
121
+ ctx.ui.setWidget("hwcode-welcome", [
122
+ `当前模型:${model}`,
123
+ "输入任何内容开始会话",
124
+ "命令:/model 切换模型",
125
+ ]);
126
+ }
127
+
128
+ function dismissWelcome(ctx: ExtensionContext) {
129
+ if (!welcomeVisible) return;
130
+
131
+ welcomeVisible = false;
132
+ ctx.ui.setHeader(undefined);
133
+ ctx.ui.setWidget("hwcode-welcome", undefined);
134
+ clearTerminal?.();
135
+ clearTerminal = undefined;
136
+ }
137
+
138
+ pi.on("session_start", (event, ctx) => {
139
+ if (ctx.mode === "tui") {
140
+ ctx.ui.onTerminalInput((data) => {
141
+ if (welcomeVisible && shouldDismissWelcomeOnSubmit(data, ctx.ui.getEditorText())) {
142
+ dismissWelcome(ctx);
143
+ }
144
+ return undefined;
145
+ });
146
+ }
147
+ if (event.reason === "startup") showWelcome(ctx);
148
+ });
149
+
150
+ pi.on("input", (_event, ctx) => {
151
+ dismissWelcome(ctx);
152
+ return { action: "continue" };
153
+ });
154
+
155
+ pi.on("before_agent_start", (_event, ctx) => {
156
+ dismissWelcome(ctx);
157
+ });
158
+
159
+ pi.on("user_bash", (_event, ctx) => {
160
+ dismissWelcome(ctx);
161
+ return undefined;
162
+ });
163
+
164
+ pi.registerCommand("welcome", {
165
+ description: "Show the HWCode welcome panel",
166
+ handler: async (_args, ctx) => showWelcome(ctx),
167
+ });
168
+ }