@hadooppei/hwcode 0.1.0 → 0.2.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.
@@ -7,7 +7,7 @@ import { createLocalBashOperations } from "@earendil-works/pi-coding-agent";
7
7
 
8
8
  import {
9
9
  canonicalizeDirectory,
10
- findChainedDirectoryChange,
10
+ clearWorkingDirectoryState,
11
11
  getActiveWorkflowRoot,
12
12
  getWorkingDirectory,
13
13
  getWorkingDirectoryState,
@@ -20,9 +20,8 @@ import {
20
20
  type WorkingDirectoryState,
21
21
  WORKING_DIRECTORY_STATE_TYPE,
22
22
  } from "../lib/working-directory.ts";
23
-
24
- const FILE_PATH_TOOLS = new Set(["read", "write", "edit", "grep", "find", "ls"]);
25
- const OPTIONAL_PATH_TOOLS = new Set(["grep", "find", "ls"]);
23
+ import { isPathInsideRoot } from "../lib/workflow-guard.ts";
24
+ import { FILE_PATH_TOOLS, OPTIONAL_PATH_TOOLS } from "../lib/workspace/access-policy.ts";
26
25
 
27
26
  interface ChangeResult {
28
27
  state?: WorkingDirectoryState;
@@ -60,7 +59,7 @@ export default function workingDirectoryExtension(pi: ExtensionAPI) {
60
59
  } catch {
61
60
  // The workflow guard will handle a missing locked root separately.
62
61
  }
63
- if (target !== canonicalWorkflowRoot) {
62
+ if (!isPathInsideRoot(canonicalWorkflowRoot, target)) {
64
63
  return {
65
64
  error: `The active HWCode workflow locks this session to ${workflowRoot}. Start a new session before switching to ${target}.`,
66
65
  };
@@ -119,7 +118,7 @@ export default function workingDirectoryExtension(pi: ExtensionAPI) {
119
118
  `\nCurrent working directory: ${cwd}`,
120
119
  );
121
120
  return {
122
- systemPrompt: `${prompt}\nA leading shell \`cd\` changes the persistent working directory for this session. All relative shell and built-in file-tool paths resolve from the current working directory.`,
121
+ systemPrompt: `${prompt}\nOnly a leading shell \`cd\` changes the persistent working directory for this session. Conditional or later \`cd\` commands remain local to that shell process. All relative shell and built-in file-tool paths resolve from the current execution directory. Changing it does not reload Pi project resources; start a new session from another project when its AGENTS.md, settings, or skills must be loaded.`,
123
122
  };
124
123
  });
125
124
 
@@ -127,7 +126,7 @@ export default function workingDirectoryExtension(pi: ExtensionAPI) {
127
126
  const input = event.input as Record<string, unknown>;
128
127
  if (event.toolName === "bash" && typeof input.command === "string") {
129
128
  const executionCwd = getWorkingDirectory(ctx.sessionManager);
130
- const change = findChainedDirectoryChange(input.command);
129
+ const change = parseLeadingDirectoryChange(input.command);
131
130
  if (change) {
132
131
  const result = changeDirectory(change.argument, ctx);
133
132
  if (result.error) return { block: true, reason: result.error };
@@ -149,7 +148,7 @@ export default function workingDirectoryExtension(pi: ExtensionAPI) {
149
148
 
150
149
  pi.on("user_bash", (event, ctx) => {
151
150
  const executionCwd = getWorkingDirectory(ctx.sessionManager);
152
- const change = findChainedDirectoryChange(event.command);
151
+ const change = parseLeadingDirectoryChange(event.command);
153
152
  if (change) {
154
153
  const result = changeDirectory(change.argument, ctx);
155
154
  if (result.error) {
@@ -162,7 +161,7 @@ export default function workingDirectoryExtension(pi: ExtensionAPI) {
162
161
  },
163
162
  };
164
163
  }
165
- if (change.standalone) {
164
+ if (!change.remainder) {
166
165
  return {
167
166
  result: {
168
167
  output: `${result.state!.cwd}\n`,
@@ -185,4 +184,8 @@ export default function workingDirectoryExtension(pi: ExtensionAPI) {
185
184
  };
186
185
  return { operations };
187
186
  });
187
+
188
+ pi.on("session_shutdown", (_event, ctx) => {
189
+ clearWorkingDirectoryState(ctx.sessionManager);
190
+ });
188
191
  }
@@ -1,6 +1,3 @@
1
- import { existsSync, readFileSync } from "node:fs";
2
- import { dirname, join, resolve } from "node:path";
3
- import { fileURLToPath } from "node:url";
4
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
2
  import type {
6
3
  ApiKeyCredential,
@@ -11,145 +8,35 @@ import type {
11
8
  } from "@earendil-works/pi-ai";
12
9
  import { stream, streamSimple } from "@earendil-works/pi-ai/compat";
13
10
 
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
- }
11
+ import {
12
+ DEFAULT_CONTEXT_POLICY,
13
+ loadContextPolicy,
14
+ resolveContextWindow,
15
+ type ContextPolicy,
16
+ } from "../lib/context-policy.ts";
17
+ import {
18
+ isPositiveNumber,
19
+ isRecord,
20
+ loadModelProvidersConfig,
21
+ normalizeBaseUrl,
22
+ type ModelInput,
23
+ type ModelProviderConfig,
24
+ } from "../lib/models/provider-config.ts";
52
25
 
53
- interface ModelProvidersConfig {
54
- providers: ModelProviderConfig[];
55
- }
26
+ type OpenAIModel = Model<"openai-completions">;
56
27
 
57
28
  interface RemoteModel {
58
29
  id: string;
59
30
  name?: string;
60
31
  input?: ModelInput[];
32
+ contextWindow?: number;
61
33
  }
62
34
 
63
- const BUNDLED_CONFIG_PATH = join(dirname(fileURLToPath(import.meta.url)), "..", "model-providers.json");
64
35
  const CREDENTIAL_BASE_URL = "BASE_URL";
65
- const DEFAULT_CONTEXT_WINDOW = 32768;
66
36
  const DEFAULT_MAX_TOKENS = 8192;
67
37
  const DEFAULT_CATALOG_PATH = "models";
68
38
  const DEFAULT_TIMEOUT_MS = 15000;
69
39
 
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
40
 
154
41
  function compatibility() {
155
42
  return {
@@ -165,10 +52,15 @@ function toPiModel(
165
52
  provider: ModelProviderConfig,
166
53
  baseUrl: string,
167
54
  remote: RemoteModel,
55
+ contextPolicy: ContextPolicy,
168
56
  ): OpenAIModel {
169
57
  const override = provider.models?.find((model) => model.id === remote.id);
170
58
  const defaults = provider.modelDefaults;
171
- const contextWindow = override?.contextWindow ?? defaults?.contextWindow ?? DEFAULT_CONTEXT_WINDOW;
59
+ const contextWindow = resolveContextWindow(
60
+ override?.contextWindow ?? defaults?.contextWindow,
61
+ remote.contextWindow,
62
+ contextPolicy,
63
+ );
172
64
  const maxTokens = override?.maxTokens ?? defaults?.maxTokens ?? DEFAULT_MAX_TOKENS;
173
65
 
174
66
  return {
@@ -196,6 +88,22 @@ function remoteInput(model: Record<string, unknown>): ModelInput[] | undefined {
196
88
  return input;
197
89
  }
198
90
 
91
+ function remoteContextWindow(model: Record<string, unknown>): number | undefined {
92
+ const architecture = isRecord(model.architecture) ? model.architecture : undefined;
93
+ for (const value of [
94
+ model.context_window,
95
+ model.context_length,
96
+ model.max_context_length,
97
+ model.max_model_len,
98
+ model.n_ctx,
99
+ architecture?.context_window,
100
+ architecture?.context_length,
101
+ ]) {
102
+ if (isPositiveNumber(value)) return Math.floor(value);
103
+ }
104
+ return undefined;
105
+ }
106
+
199
107
  function parseRemoteModels(payload: unknown): RemoteModel[] {
200
108
  const entries = Array.isArray(payload)
201
109
  ? payload
@@ -215,6 +123,7 @@ function parseRemoteModels(payload: unknown): RemoteModel[] {
215
123
  id,
216
124
  name: typeof entry.name === "string" && entry.name.trim() ? entry.name.trim() : undefined,
217
125
  input: remoteInput(entry),
126
+ contextWindow: remoteContextWindow(entry),
218
127
  });
219
128
  }
220
129
 
@@ -235,6 +144,7 @@ async function discoverModels(
235
144
  baseUrl: string,
236
145
  apiKey: string | undefined,
237
146
  signal: AbortSignal,
147
+ contextPolicy: ContextPolicy,
238
148
  ): Promise<OpenAIModel[]> {
239
149
  const login = provider.login;
240
150
  if (!login) throw new Error(`Provider "${provider.id}" has no login configuration`);
@@ -259,7 +169,7 @@ async function discoverModels(
259
169
  throw new Error(`Model discovery failed with HTTP ${response.status}${detail ? `: ${detail}` : ""}`);
260
170
  }
261
171
 
262
- return parseRemoteModels(payload).map((model) => toPiModel(provider, baseUrl, model));
172
+ return parseRemoteModels(payload).map((model) => toPiModel(provider, baseUrl, model, contextPolicy));
263
173
  }
264
174
 
265
175
  async function environmentValue(name: string | undefined): Promise<string | undefined> {
@@ -287,7 +197,11 @@ async function configuredBaseUrl(
287
197
  return provider.baseUrl ? normalizeBaseUrl(provider.baseUrl) : undefined;
288
198
  }
289
199
 
290
- function registerStaticProvider(pi: ExtensionAPI, config: ModelProviderConfig): void {
200
+ function registerStaticProvider(
201
+ pi: ExtensionAPI,
202
+ config: ModelProviderConfig,
203
+ contextPolicy: ContextPolicy,
204
+ ): void {
291
205
  const baseUrl = normalizeBaseUrl(config.baseUrl!);
292
206
  const apiKey = config.apiKeyEnv
293
207
  ? process.env[config.apiKeyEnv]?.trim() || "local"
@@ -299,21 +213,32 @@ function registerStaticProvider(pi: ExtensionAPI, config: ModelProviderConfig):
299
213
  api: "openai-completions",
300
214
  apiKey,
301
215
  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
- })),
216
+ models: (config.models ?? []).map((model) => {
217
+ const contextWindow = resolveContextWindow(
218
+ model.contextWindow ?? config.modelDefaults?.contextWindow,
219
+ undefined,
220
+ contextPolicy,
221
+ );
222
+ return {
223
+ id: model.id,
224
+ name: model.name ?? model.id,
225
+ reasoning: model.reasoning ?? config.modelDefaults?.reasoning ?? false,
226
+ input: model.input ?? config.modelDefaults?.input ?? ["text"],
227
+ contextWindow,
228
+ maxTokens: Math.min(
229
+ model.maxTokens ?? config.modelDefaults?.maxTokens ?? DEFAULT_MAX_TOKENS,
230
+ contextWindow,
231
+ ),
232
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
233
+ };
234
+ }),
311
235
  });
312
236
  }
313
237
 
314
238
  async function loginAndDiscover(
315
239
  config: ModelProviderConfig,
316
240
  interaction: ProviderAuthInteraction,
241
+ contextPolicy: ContextPolicy,
317
242
  ): Promise<{ credential: ApiKeyCredential; models: OpenAIModel[] }> {
318
243
  const login = config.login!;
319
244
  try {
@@ -343,7 +268,7 @@ async function loginAndDiscover(
343
268
  }
344
269
 
345
270
  interaction.notify({ type: "progress", message: `Discovering models from ${baseUrl}` });
346
- const models = await discoverModels(config, baseUrl, apiKey, interaction.signal);
271
+ const models = await discoverModels(config, baseUrl, apiKey, interaction.signal, contextPolicy);
347
272
  interaction.notify({ type: "info", message: `Found ${models.length} model${models.length === 1 ? "" : "s"}.` });
348
273
 
349
274
  return {
@@ -362,7 +287,10 @@ async function loginAndDiscover(
362
287
  }
363
288
  }
364
289
 
365
- function createLoginProvider(config: ModelProviderConfig): Provider<"openai-completions"> {
290
+ function createLoginProvider(
291
+ config: ModelProviderConfig,
292
+ contextPolicy: ContextPolicy,
293
+ ): Provider<"openai-completions"> {
366
294
  const login = config.login!;
367
295
  const displayName = config.name ?? config.id;
368
296
  let models: OpenAIModel[] = config.baseUrl
@@ -370,12 +298,15 @@ function createLoginProvider(config: ModelProviderConfig): Provider<"openai-comp
370
298
  config,
371
299
  normalizeBaseUrl(config.baseUrl!),
372
300
  { id: model.id, name: model.name, input: model.input },
301
+ contextPolicy,
373
302
  ))
374
303
  : [];
375
304
 
376
305
  const ambientApiKey = () => environmentValue(config.apiKeyEnv);
377
306
  const ambientBaseUrl = () => environmentValue(config.baseUrlEnv);
378
- const hasAmbientConfiguration = async () => Boolean(await ambientApiKey() || await ambientBaseUrl());
307
+ const hasAmbientConfiguration = async () => Boolean(
308
+ config.baseUrl || await ambientApiKey() || await ambientBaseUrl()
309
+ );
379
310
 
380
311
  return {
381
312
  id: config.id,
@@ -385,7 +316,7 @@ function createLoginProvider(config: ModelProviderConfig): Provider<"openai-comp
385
316
  apiKey: {
386
317
  name: `${displayName} credentials`,
387
318
  login: async (interaction: ProviderAuthInteraction) => {
388
- const result = await loginAndDiscover(config, interaction);
319
+ const result = await loginAndDiscover(config, interaction, contextPolicy);
389
320
  models = result.models;
390
321
  return result.credential;
391
322
  },
@@ -416,7 +347,12 @@ function createLoginProvider(config: ModelProviderConfig): Provider<"openai-comp
416
347
  if (context.stored) {
417
348
  const restored = context.stored.models.filter(
418
349
  (model): model is OpenAIModel => model.provider === config.id && model.api === "openai-completions",
419
- );
350
+ ).map((model) => {
351
+ const configured = config.models?.find((item) => item.id === model.id)?.contextWindow
352
+ ?? config.modelDefaults?.contextWindow;
353
+ const contextWindow = resolveContextWindow(configured, model.contextWindow, contextPolicy);
354
+ return { ...model, contextWindow, maxTokens: Math.min(model.maxTokens, contextWindow) };
355
+ });
420
356
  if (!await context.publish({ update: () => { models = restored; } })) return;
421
357
  }
422
358
  if (!context.allowNetwork || context.signal.aborted) return;
@@ -430,7 +366,7 @@ function createLoginProvider(config: ModelProviderConfig): Provider<"openai-comp
430
366
  const apiKey = credential?.key ?? await ambientApiKey();
431
367
  if (!baseUrl || (login.apiKeyRequired && !apiKey)) return;
432
368
 
433
- const refreshed = await discoverModels(config, baseUrl, apiKey, context.signal);
369
+ const refreshed = await discoverModels(config, baseUrl, apiKey, context.signal, contextPolicy);
434
370
  await context.publish({
435
371
  persist: { models: refreshed, checkedAt: Date.now() },
436
372
  update: () => { models = refreshed; },
@@ -442,9 +378,15 @@ function createLoginProvider(config: ModelProviderConfig): Provider<"openai-comp
442
378
  }
443
379
 
444
380
  export default function modelProvidersExtension(pi: ExtensionAPI) {
445
- const config = loadConfig();
381
+ const config = loadModelProvidersConfig();
382
+ let contextPolicy = { ...DEFAULT_CONTEXT_POLICY };
383
+ try {
384
+ contextPolicy = loadContextPolicy(process.cwd());
385
+ } catch {
386
+ // The context extension reports malformed settings in the UI. Providers stay usable with safe defaults.
387
+ }
446
388
  for (const provider of config.providers) {
447
- if (provider.login?.enabled) pi.registerProvider(createLoginProvider(provider));
448
- else registerStaticProvider(pi, provider);
389
+ if (provider.login?.enabled) pi.registerProvider(createLoginProvider(provider, contextPolicy));
390
+ else registerStaticProvider(pi, provider, contextPolicy);
449
391
  }
450
392
  }
@@ -1,13 +1,12 @@
1
- import { existsSync, readFileSync } from "node:fs";
2
1
  import { dirname, join } from "node:path";
3
2
  import { fileURLToPath } from "node:url";
4
3
  import {
5
- CONFIG_DIR_NAME,
6
4
  type ExtensionAPI,
7
5
  type ExtensionContext,
8
6
  type ThemeColor,
9
7
  } from "@earendil-works/pi-coding-agent";
10
8
  import { isThemeColor, renderPixelText } from "../lib/pixel-font.ts";
9
+ import { loadReplacingResource } from "../lib/runtime/config.ts";
11
10
  import { shouldDismissWelcomeOnSubmit } from "../lib/welcome-input.ts";
12
11
 
13
12
  interface WelcomeConfig {
@@ -17,6 +16,8 @@ interface WelcomeConfig {
17
16
  subtitle: string;
18
17
  subtitleColor: ThemeColor;
19
18
  reservedRows: number;
19
+ logoScale: number;
20
+ verticalOffsetRows: number;
20
21
  }
21
22
 
22
23
  const DEFAULT_CONFIG: WelcomeConfig = {
@@ -26,15 +27,17 @@ const DEFAULT_CONFIG: WelcomeConfig = {
26
27
  subtitle: "Local AI Coding Workspace",
27
28
  subtitleColor: "muted",
28
29
  reservedRows: 8,
30
+ logoScale: 0.82,
31
+ verticalOffsetRows: 1,
29
32
  };
30
33
 
31
34
  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>;
35
+ const fallbackPath = join(dirname(fileURLToPath(import.meta.url)), "..", "welcome.json");
36
+ const raw = loadReplacingResource<Partial<WelcomeConfig> & Record<string, unknown>>(
37
+ cwd,
38
+ "welcome.json",
39
+ fallbackPath,
40
+ );
38
41
  const logo = raw.logo?.trim().toUpperCase() ?? DEFAULT_CONFIG.logo;
39
42
 
40
43
  if (!/^[A-Z]{1,6}$/.test(logo)) {
@@ -59,6 +62,14 @@ function loadConfig(cwd: string): WelcomeConfig {
59
62
  typeof raw.reservedRows === "number" && raw.reservedRows >= 5
60
63
  ? Math.floor(raw.reservedRows)
61
64
  : DEFAULT_CONFIG.reservedRows,
65
+ logoScale:
66
+ typeof raw.logoScale === "number" && raw.logoScale >= 0.5 && raw.logoScale <= 1
67
+ ? raw.logoScale
68
+ : DEFAULT_CONFIG.logoScale,
69
+ verticalOffsetRows:
70
+ typeof raw.verticalOffsetRows === "number" && raw.verticalOffsetRows >= -4 && raw.verticalOffsetRows <= 4
71
+ ? Math.trunc(raw.verticalOffsetRows)
72
+ : DEFAULT_CONFIG.verticalOffsetRows,
62
73
  };
63
74
  }
64
75
 
@@ -101,9 +112,14 @@ export default function welcomeExtension(pi: ExtensionAPI) {
101
112
  theme,
102
113
  width,
103
114
  Math.max(1, availableHeight - subtitleHeight),
115
+ config.logoScale,
104
116
  );
105
117
  const groupHeight = logo.height + subtitleHeight;
106
- const topPadding = Math.max(0, Math.floor((availableHeight - groupHeight) / 2));
118
+ const centeredTop = Math.max(0, Math.floor((availableHeight - groupHeight) / 2));
119
+ const topPadding = Math.max(
120
+ 0,
121
+ Math.min(availableHeight - groupHeight, centeredTop + config.verticalOffsetRows),
122
+ );
107
123
  const bottomPadding = Math.max(0, availableHeight - topPadding - groupHeight);
108
124
  const lines = [...Array<string>(topPadding).fill(""), ...logo.lines];
109
125