@oh-my-pi/pi-coding-agent 3.31.0 → 3.33.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,35 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [3.33.0] - 2026-01-08
6
+
7
+ ### Added
8
+ - Added `env` support in `settings.json` for automatically setting environment variables on startup
9
+ - Added environment variable management methods to SettingsManager (get/set/clear)
10
+
11
+ ### Fixed
12
+ - Fixed bash output previews to recompute on resize, preventing TUI line width overflow crashes
13
+ - Fixed session title generation to retry alternate smol models when the primary model errors or is rate-limited
14
+ - Fixed file mentions to resolve extensionless paths and directories, using read tool truncation limits for injected content
15
+ - Fixed interactive UI to show auto-read file mention indicators
16
+ - Fixed task tool tree rendering to use consistent tree connectors for progress, findings, and results
17
+ - Fixed last-branch tree connector symbol in the TUI
18
+ - Fixed output tool previews to use compact JSON when outputs are formatted with leading braces
19
+
20
+ ## [3.32.0] - 2026-01-08
21
+ ### Added
22
+
23
+ - Added progress indicator when starting LSP servers at session startup
24
+ - Added bundled `/init` slash command available by default
25
+
26
+ ### Changed
27
+
28
+ - Changed LSP server warmup to use a 5-second timeout, falling back to lazy initialization for slow servers
29
+
30
+ ### Fixed
31
+
32
+ - Fixed Task tool subagent model selection to inherit explicit CLI `--model` overrides
33
+
5
34
  ## [3.31.0] - 2026-01-08
6
35
 
7
36
  ### Added
package/README.md CHANGED
@@ -338,10 +338,22 @@ When disabled, neither case triggers automatic compaction (use `/compact` manual
338
338
  "enabled": true,
339
339
  "reserveTokens": 16384,
340
340
  "keepRecentTokens": 20000
341
+ },
342
+ "env": {
343
+ "ANTHROPIC_API_KEY": "sk-ant-...",
344
+ "OPENAI_API_KEY": "sk-proj-...",
345
+ "GEMINI_API_KEY": "AIzaSyD...",
346
+ "CUSTOM_VAR": "custom-value"
341
347
  }
342
348
  }
343
349
  ```
344
350
 
351
+ **Environment Variables (`env`):**
352
+ - Automatically sets environment variables when the application starts
353
+ - Only sets variables that aren't already present in `process.env`
354
+ - Supports any environment variable, not just API keys
355
+ - Order of precedence: existing env vars > settings.json env vars > auth.json env vars
356
+
345
357
  > **Note:** Compaction is lossy. The agent loses full conversation access afterward. Size tasks to avoid context limits when possible. For critical context, ask the agent to write a summary to a file, iterate on it until it covers everything, then start a new session with that file. The full session history is preserved in the JSONL file; use `/tree` to revisit any previous point.
346
358
 
347
359
  See [docs/compaction.md](docs/compaction.md) for how compaction works internally and how to customize it via hooks.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-coding-agent",
3
- "version": "3.31.0",
3
+ "version": "3.33.0",
4
4
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
5
5
  "type": "module",
6
6
  "ompConfig": {
@@ -40,9 +40,9 @@
40
40
  },
41
41
  "dependencies": {
42
42
  "@mariozechner/pi-ai": "^0.37.8",
43
- "@oh-my-pi/pi-agent-core": "3.31.0",
44
- "@oh-my-pi/pi-git-tool": "3.31.0",
45
- "@oh-my-pi/pi-tui": "3.31.0",
43
+ "@oh-my-pi/pi-agent-core": "3.33.0",
44
+ "@oh-my-pi/pi-git-tool": "3.33.0",
45
+ "@oh-my-pi/pi-tui": "3.33.0",
46
46
  "@openai/agents": "^0.3.7",
47
47
  "@sinclair/typebox": "^0.34.46",
48
48
  "ajv": "^8.17.1",
@@ -377,7 +377,8 @@ export class AgentSession {
377
377
  } else if (
378
378
  event.message.role === "user" ||
379
379
  event.message.role === "assistant" ||
380
- event.message.role === "toolResult"
380
+ event.message.role === "toolResult" ||
381
+ event.message.role === "fileMention"
381
382
  ) {
382
383
  // Regular LLM message - persist as SessionMessageEntry
383
384
  this.sessionManager.appendMessage(event.message);
@@ -435,13 +435,11 @@ export function createReviewCommand(api: CustomCommandAPI): CustomCommand {
435
435
  if (hasDiff) {
436
436
  const stats = parseDiff(diffResult.stdout);
437
437
  // Even if all files filtered, include the custom instructions
438
- return (
439
- buildReviewPrompt(
440
- `Custom review: ${instructions.split("\n")[0].slice(0, 60)}...`,
441
- stats,
442
- diffResult.stdout,
443
- ) + `\n\n### Additional Instructions\n\n${instructions}`
444
- );
438
+ return `${buildReviewPrompt(
439
+ `Custom review: ${instructions.split("\n")[0].slice(0, 60)}...`,
440
+ stats,
441
+ diffResult.stdout,
442
+ )}\n\n### Additional Instructions\n\n${instructions}`;
445
443
  }
446
444
 
447
445
  // No diff available, just pass instructions
@@ -9,14 +9,148 @@
9
9
  import path from "node:path";
10
10
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
11
11
  import type { FileMentionMessage } from "./messages";
12
+ import { resolveReadPath } from "./tools/path-utils";
13
+ import { formatAge } from "./tools/render-utils";
14
+ import { DEFAULT_MAX_BYTES, formatSize, truncateHead, truncateStringToBytesFromStart } from "./tools/truncate";
12
15
 
13
16
  /** Regex to match @filepath patterns in text */
14
- const FILE_MENTION_REGEX = /@((?:[^\s@]+\/)*[^\s@]+\.[a-zA-Z0-9]+)/g;
17
+ const FILE_MENTION_REGEX = /@([^\s@]+)/g;
18
+ const LEADING_PUNCTUATION_REGEX = /^[`"'([{<]+/;
19
+ const TRAILING_PUNCTUATION_REGEX = /[)\]}>.,;:!?"'`]+$/;
20
+ const MENTION_BOUNDARY_REGEX = /[\s([{<"'`]/;
21
+ const DEFAULT_DIR_LIMIT = 500;
22
+
23
+ function isMentionBoundary(text: string, index: number): boolean {
24
+ if (index === 0) return true;
25
+ return MENTION_BOUNDARY_REGEX.test(text[index - 1]);
26
+ }
27
+
28
+ function sanitizeMentionPath(rawPath: string): string | null {
29
+ let cleaned = rawPath.trim();
30
+ cleaned = cleaned.replace(LEADING_PUNCTUATION_REGEX, "");
31
+ cleaned = cleaned.replace(TRAILING_PUNCTUATION_REGEX, "");
32
+ cleaned = cleaned.trim();
33
+ return cleaned.length > 0 ? cleaned : null;
34
+ }
35
+
36
+ function buildTextOutput(textContent: string): { output: string; lineCount: number } {
37
+ const allLines = textContent.split("\n");
38
+ const totalFileLines = allLines.length;
39
+ const truncation = truncateHead(textContent);
40
+
41
+ if (truncation.firstLineExceedsLimit) {
42
+ const firstLine = allLines[0] ?? "";
43
+ const firstLineBytes = Buffer.byteLength(firstLine, "utf-8");
44
+ const snippet = truncateStringToBytesFromStart(firstLine, DEFAULT_MAX_BYTES);
45
+ let outputText = snippet.text;
46
+
47
+ if (outputText.length > 0) {
48
+ outputText += `\n\n[Line 1 is ${formatSize(firstLineBytes)}, exceeds ${formatSize(
49
+ DEFAULT_MAX_BYTES,
50
+ )} limit. Showing first ${formatSize(snippet.bytes)} of the line.]`;
51
+ } else {
52
+ outputText = `[Line 1 is ${formatSize(firstLineBytes)}, exceeds ${formatSize(
53
+ DEFAULT_MAX_BYTES,
54
+ )} limit. Unable to display a valid UTF-8 snippet.]`;
55
+ }
56
+
57
+ return { output: outputText, lineCount: totalFileLines };
58
+ }
59
+
60
+ let outputText = truncation.content;
61
+
62
+ if (truncation.truncated) {
63
+ const endLineDisplay = truncation.outputLines;
64
+ const nextOffset = endLineDisplay + 1;
65
+
66
+ if (truncation.truncatedBy === "lines") {
67
+ outputText += `\n\n[Showing lines 1-${endLineDisplay} of ${totalFileLines}. Use offset=${nextOffset} to continue]`;
68
+ } else {
69
+ outputText += `\n\n[Showing lines 1-${endLineDisplay} of ${totalFileLines} (${formatSize(
70
+ DEFAULT_MAX_BYTES,
71
+ )} limit). Use offset=${nextOffset} to continue]`;
72
+ }
73
+ }
74
+
75
+ return { output: outputText, lineCount: totalFileLines };
76
+ }
77
+
78
+ async function buildDirectoryListing(absolutePath: string): Promise<{ output: string; lineCount: number }> {
79
+ let entries: string[];
80
+ try {
81
+ entries = await Array.fromAsync(new Bun.Glob("*").scan({ cwd: absolutePath, dot: true, onlyFiles: false }));
82
+ } catch {
83
+ return { output: "(empty directory)", lineCount: 1 };
84
+ }
85
+
86
+ entries.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
87
+
88
+ const results: string[] = [];
89
+ let entryLimitReached = false;
90
+
91
+ for (const entry of entries) {
92
+ if (results.length >= DEFAULT_DIR_LIMIT) {
93
+ entryLimitReached = true;
94
+ break;
95
+ }
96
+
97
+ const fullPath = path.join(absolutePath, entry);
98
+ let suffix = "";
99
+ let age = "";
100
+
101
+ try {
102
+ const stat = await Bun.file(fullPath).stat();
103
+ if (stat.isDirectory()) {
104
+ suffix = "/";
105
+ }
106
+ const ageSeconds = Math.floor((Date.now() - stat.mtimeMs) / 1000);
107
+ age = formatAge(ageSeconds);
108
+ } catch {
109
+ continue;
110
+ }
111
+
112
+ const line = age ? `${entry}${suffix} (${age})` : `${entry}${suffix}`;
113
+ results.push(line);
114
+ }
115
+
116
+ if (results.length === 0) {
117
+ return { output: "(empty directory)", lineCount: 1 };
118
+ }
119
+
120
+ const rawOutput = results.join("\n");
121
+ const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
122
+ let output = truncation.content;
123
+
124
+ const notices: string[] = [];
125
+ if (entryLimitReached) {
126
+ notices.push(`${DEFAULT_DIR_LIMIT} entries limit reached. Use limit=${DEFAULT_DIR_LIMIT * 2} for more`);
127
+ }
128
+ if (truncation.truncated) {
129
+ notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`);
130
+ }
131
+ if (notices.length > 0) {
132
+ output += `\n\n[${notices.join(". ")}]`;
133
+ }
134
+
135
+ return { output, lineCount: output.split("\n").length };
136
+ }
15
137
 
16
138
  /** Extract all @filepath mentions from text */
17
139
  export function extractFileMentions(text: string): string[] {
18
140
  const matches = [...text.matchAll(FILE_MENTION_REGEX)];
19
- return [...new Set(matches.map((m) => m[1]))];
141
+ const mentions: string[] = [];
142
+
143
+ for (const match of matches) {
144
+ const index = match.index ?? 0;
145
+ if (!isMentionBoundary(text, index)) continue;
146
+
147
+ const cleaned = sanitizeMentionPath(match[1]);
148
+ if (!cleaned) continue;
149
+
150
+ mentions.push(cleaned);
151
+ }
152
+
153
+ return [...new Set(mentions)];
20
154
  }
21
155
 
22
156
  /**
@@ -29,11 +163,19 @@ export async function generateFileMentionMessages(filePaths: string[], cwd: stri
29
163
  const files: FileMentionMessage["files"] = [];
30
164
 
31
165
  for (const filePath of filePaths) {
166
+ const absolutePath = resolveReadPath(filePath, cwd);
167
+
32
168
  try {
33
- const absolutePath = path.resolve(cwd, filePath);
169
+ const stat = await Bun.file(absolutePath).stat();
170
+ if (stat.isDirectory()) {
171
+ const { output, lineCount } = await buildDirectoryListing(absolutePath);
172
+ files.push({ path: filePath, content: output, lineCount });
173
+ continue;
174
+ }
175
+
34
176
  const content = await Bun.file(absolutePath).text();
35
- const lineCount = content.split("\n").length;
36
- files.push({ path: filePath, content, lineCount });
177
+ const { output, lineCount } = buildTextOutput(content);
178
+ files.push({ path: filePath, content: output, lineCount });
37
179
  } catch {
38
180
  // File doesn't exist or isn't readable - skip silently
39
181
  }
@@ -33,7 +33,7 @@ export interface ScopedModel {
33
33
  }
34
34
 
35
35
  /** Priority chain for auto-discovering smol/fast models */
36
- export const SMOL_MODEL_PRIORITY = ["claude-haiku-4-5", "haiku", "flash", "mini"];
36
+ export const SMOL_MODEL_PRIORITY = ["cerebras/zai-glm-4.6", "claude-haiku-4-5", "haiku", "flash", "mini"];
37
37
 
38
38
  /** Priority chain for auto-discovering slow/comprehensive models (reasoning, codex) */
39
39
  export const SLOW_MODEL_PRIORITY = ["gpt-5.2-codex", "gpt-5.2", "codex", "gpt", "opus", "pro"];
@@ -79,7 +79,7 @@ function tryMatchModel(modelPattern: string, availableModels: Model<Api>[]): Mod
79
79
  const provider = modelPattern.substring(0, slashIndex);
80
80
  const modelId = modelPattern.substring(slashIndex + 1);
81
81
  const providerMatch = availableModels.find(
82
- (m) => m.provider.toLowerCase() === provider.toLowerCase() && m.id.toLowerCase() === modelId.toLowerCase(),
82
+ (m) => m.provider.toLowerCase() === provider.toLowerCase() && m.id.toLowerCase() === modelId.toLowerCase()
83
83
  );
84
84
  if (providerMatch) {
85
85
  return providerMatch;
@@ -97,7 +97,7 @@ function tryMatchModel(modelPattern: string, availableModels: Model<Api>[]): Mod
97
97
  const matches = availableModels.filter(
98
98
  (m) =>
99
99
  m.id.toLowerCase().includes(modelPattern.toLowerCase()) ||
100
- m.name?.toLowerCase().includes(modelPattern.toLowerCase()),
100
+ m.name?.toLowerCase().includes(modelPattern.toLowerCase())
101
101
  );
102
102
 
103
103
  if (matches.length === 0) {
@@ -351,7 +351,7 @@ export async function restoreModelFromSession(
351
351
  savedModelId: string,
352
352
  currentModel: Model<Api> | undefined,
353
353
  shouldPrintMessages: boolean,
354
- modelRegistry: ModelRegistry,
354
+ modelRegistry: ModelRegistry
355
355
  ): Promise<{ model: Model<Api> | undefined; fallbackMessage: string | undefined }> {
356
356
  const restoredModel = modelRegistry.find(savedProvider, savedModelId);
357
357
 
@@ -427,7 +427,7 @@ export async function restoreModelFromSession(
427
427
  */
428
428
  export async function findSmolModel(
429
429
  modelRegistry: ModelRegistry,
430
- savedModel?: string,
430
+ savedModel?: string
431
431
  ): Promise<Model<Api> | undefined> {
432
432
  const availableModels = modelRegistry.getAvailable();
433
433
  if (availableModels.length === 0) return undefined;
@@ -443,12 +443,16 @@ export async function findSmolModel(
443
443
 
444
444
  // 2. Try priority chain
445
445
  for (const pattern of SMOL_MODEL_PRIORITY) {
446
+ // Try exact match with provider prefix
447
+ const providerMatch = availableModels.find((m) => `${m.provider}/${m.id}`.toLowerCase() === pattern);
448
+ if (providerMatch) return providerMatch;
449
+
446
450
  // Try exact match first
447
- const exactMatch = availableModels.find((m) => m.id.toLowerCase() === pattern.toLowerCase());
451
+ const exactMatch = availableModels.find((m) => m.id.toLowerCase() === pattern);
448
452
  if (exactMatch) return exactMatch;
449
453
 
450
454
  // Try fuzzy match (substring)
451
- const fuzzyMatch = availableModels.find((m) => m.id.toLowerCase().includes(pattern.toLowerCase()));
455
+ const fuzzyMatch = availableModels.find((m) => m.id.toLowerCase().includes(pattern));
452
456
  if (fuzzyMatch) return fuzzyMatch;
453
457
  }
454
458
 
@@ -466,7 +470,7 @@ export async function findSmolModel(
466
470
  */
467
471
  export async function findSlowModel(
468
472
  modelRegistry: ModelRegistry,
469
- savedModel?: string,
473
+ savedModel?: string
470
474
  ): Promise<Model<Api> | undefined> {
471
475
  const availableModels = modelRegistry.getAvailable();
472
476
  if (availableModels.length === 0) return undefined;
package/src/core/sdk.ts CHANGED
@@ -61,7 +61,7 @@ import { logger } from "./logger";
61
61
  import { discoverAndLoadMCPTools, type MCPManager, type MCPToolsLoadResult } from "./mcp/index";
62
62
  import { convertToLlm } from "./messages";
63
63
  import { ModelRegistry } from "./model-registry";
64
- import { parseModelString } from "./model-resolver";
64
+ import { formatModelString, parseModelString } from "./model-resolver";
65
65
  import { loadPromptTemplates as loadPromptTemplatesInternal, type PromptTemplate } from "./prompt-templates";
66
66
  import { SessionManager } from "./session-manager";
67
67
  import { type Settings, SettingsManager, type SkillsSettings } from "./settings-manager";
@@ -520,6 +520,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
520
520
  time("loadSession");
521
521
  const hasExistingSession = existingSession.messages.length > 0;
522
522
 
523
+ const hasExplicitModel = options.model !== undefined;
523
524
  let model = options.model;
524
525
  let modelFallbackMessage: string | undefined;
525
526
 
@@ -617,6 +618,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
617
618
  requireCompleteTool: options.requireCompleteTool,
618
619
  getSessionFile: () => sessionManager.getSessionFile() ?? null,
619
620
  getSessionSpawns: () => options.spawns ?? "*",
621
+ getModelString: () => (hasExplicitModel && model ? formatModelString(model) : undefined),
620
622
  settings: settingsManager,
621
623
  };
622
624
 
@@ -931,7 +933,13 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
931
933
  let lspServers: CreateAgentSessionResult["lspServers"];
932
934
  if (settingsManager.getLspDiagnosticsOnWrite()) {
933
935
  try {
934
- const result = await warmupLspServers(cwd);
936
+ const result = await warmupLspServers(cwd, {
937
+ onConnecting: (serverNames) => {
938
+ if (options.hasUI && serverNames.length > 0) {
939
+ process.stderr.write(chalk.gray(`Starting LSP servers: ${serverNames.join(", ")}...\n`));
940
+ }
941
+ },
942
+ });
935
943
  lspServers = result.servers;
936
944
  time("warmupLspServers");
937
945
  } catch {
@@ -11,6 +11,7 @@ import {
11
11
  createBranchSummaryMessage,
12
12
  createCompactionSummaryMessage,
13
13
  createCustomMessage,
14
+ type FileMentionMessage,
14
15
  type HookMessage,
15
16
  } from "./messages";
16
17
  import type { SessionStorage, SessionStorageWriter } from "./session-storage";
@@ -1179,7 +1180,7 @@ export class SessionManager {
1179
1180
  * so it is easier to find them.
1180
1181
  * These need to be appended via appendCompaction() and appendBranchSummary() methods.
1181
1182
  */
1182
- appendMessage(message: Message | CustomMessage | HookMessage | BashExecutionMessage): string {
1183
+ appendMessage(message: Message | CustomMessage | HookMessage | BashExecutionMessage | FileMentionMessage): string {
1183
1184
  const entry: SessionMessageEntry = {
1184
1185
  type: "message",
1185
1186
  id: generateId(this.byId),
@@ -179,6 +179,8 @@ export interface Settings {
179
179
  shellPath?: string; // Custom shell path (e.g., for Cygwin users on Windows)
180
180
  collapseChangelog?: boolean; // Show condensed changelog after update (use /changelog for full)
181
181
  doubleEscapeAction?: "branch" | "tree"; // Action for double-escape with empty editor (default: "tree")
182
+ /** Environment variables to set automatically on startup */
183
+ env?: Record<string, string>;
182
184
  extensions?: string[]; // Array of extension file paths
183
185
  skills?: SkillsSettings;
184
186
  commands?: CommandsSettings;
@@ -379,6 +381,29 @@ export class SettingsManager {
379
381
  this.globalSettings = initialSettings;
380
382
  const projectSettings = this.loadProjectSettings();
381
383
  this.settings = normalizeSettings(deepMergeSettings(this.globalSettings, projectSettings));
384
+
385
+ // Apply environment variables from settings
386
+ this.applyEnvironmentVariables();
387
+ }
388
+
389
+ /**
390
+ * Apply environment variables from settings to process.env
391
+ * Only sets variables that are not already set in the environment
392
+ */
393
+ applyEnvironmentVariables(): void {
394
+ const envVars = this.settings.env;
395
+ if (!envVars || typeof envVars !== "object") {
396
+ return;
397
+ }
398
+
399
+ for (const [key, value] of Object.entries(envVars)) {
400
+ if (typeof key === "string" && typeof value === "string") {
401
+ // Only set if not already present in environment (allow override with env vars)
402
+ if (!(key in process.env)) {
403
+ process.env[key] = value;
404
+ }
405
+ }
406
+ }
382
407
  }
383
408
 
384
409
  /** Create a SettingsManager that loads from files */
@@ -1169,4 +1194,49 @@ export class SettingsManager {
1169
1194
  this.globalSettings.doubleEscapeAction = action;
1170
1195
  this.save();
1171
1196
  }
1197
+
1198
+ /**
1199
+ * Get environment variables from settings
1200
+ */
1201
+ getEnvironmentVariables(): Record<string, string> {
1202
+ return { ...(this.settings.env ?? {}) };
1203
+ }
1204
+
1205
+ /**
1206
+ * Set environment variables in settings (not process.env)
1207
+ * This will be applied on next startup or reload
1208
+ */
1209
+ setEnvironmentVariables(envVars: Record<string, string>): void {
1210
+ this.globalSettings.env = { ...envVars };
1211
+ this.save();
1212
+ }
1213
+
1214
+ /**
1215
+ * Clear all environment variables from settings
1216
+ */
1217
+ clearEnvironmentVariables(): void {
1218
+ delete this.globalSettings.env;
1219
+ this.save();
1220
+ }
1221
+
1222
+ /**
1223
+ * Set a single environment variable in settings
1224
+ */
1225
+ setEnvironmentVariable(key: string, value: string): void {
1226
+ if (!this.globalSettings.env) {
1227
+ this.globalSettings.env = {};
1228
+ }
1229
+ this.globalSettings.env[key] = value;
1230
+ this.save();
1231
+ }
1232
+
1233
+ /**
1234
+ * Remove a single environment variable from settings
1235
+ */
1236
+ removeEnvironmentVariable(key: string): void {
1237
+ if (this.globalSettings.env) {
1238
+ delete this.globalSettings.env[key];
1239
+ this.save();
1240
+ }
1241
+ }
1172
1242
  }
@@ -2,6 +2,7 @@ import { slashCommandCapability } from "../capability/slash-command";
2
2
  import type { SlashCommand } from "../discovery";
3
3
  import { loadSync } from "../discovery";
4
4
  import { parseFrontmatter } from "../discovery/helpers";
5
+ import { EMBEDDED_COMMAND_TEMPLATES } from "./tools/task/commands";
5
6
 
6
7
  /**
7
8
  * Represents a custom slash command loaded from a file
@@ -15,6 +16,25 @@ export interface FileSlashCommand {
15
16
  _source?: { providerName: string; level: "user" | "project" | "native" };
16
17
  }
17
18
 
19
+ const EMBEDDED_SLASH_COMMANDS = EMBEDDED_COMMAND_TEMPLATES;
20
+
21
+ function parseCommandTemplate(content: string): { description: string; body: string } {
22
+ const { frontmatter, body } = parseFrontmatter(content);
23
+ const frontmatterDesc = typeof frontmatter.description === "string" ? frontmatter.description.trim() : "";
24
+
25
+ // Get description from frontmatter or first non-empty line
26
+ let description = frontmatterDesc;
27
+ if (!description) {
28
+ const firstLine = body.split("\n").find((line) => line.trim());
29
+ if (firstLine) {
30
+ description = firstLine.slice(0, 60);
31
+ if (firstLine.length > 60) description += "...";
32
+ }
33
+ }
34
+
35
+ return { description, body };
36
+ }
37
+
18
38
  /**
19
39
  * Parse command arguments respecting quoted strings (bash-style)
20
40
  * Returns array of arguments
@@ -90,19 +110,8 @@ export interface LoadSlashCommandsOptions {
90
110
  export function loadSlashCommands(options: LoadSlashCommandsOptions = {}): FileSlashCommand[] {
91
111
  const result = loadSync<SlashCommand>(slashCommandCapability.id, { cwd: options.cwd });
92
112
 
93
- return result.items.map((cmd) => {
94
- const { frontmatter, body } = parseFrontmatter(cmd.content);
95
- const frontmatterDesc = typeof frontmatter.description === "string" ? frontmatter.description.trim() : "";
96
-
97
- // Get description from frontmatter or first non-empty line
98
- let description = frontmatterDesc;
99
- if (!description) {
100
- const firstLine = body.split("\n").find((line) => line.trim());
101
- if (firstLine) {
102
- description = firstLine.slice(0, 60);
103
- if (firstLine.length > 60) description += "...";
104
- }
105
- }
113
+ const fileCommands: FileSlashCommand[] = result.items.map((cmd) => {
114
+ const { description, body } = parseCommandTemplate(cmd.content);
106
115
 
107
116
  // Format source label: "via ProviderName Level"
108
117
  const capitalizedLevel = cmd.level.charAt(0).toUpperCase() + cmd.level.slice(1);
@@ -116,6 +125,23 @@ export function loadSlashCommands(options: LoadSlashCommandsOptions = {}): FileS
116
125
  _source: { providerName: cmd._source.providerName, level: cmd.level },
117
126
  };
118
127
  });
128
+
129
+ const seenNames = new Set(fileCommands.map((cmd) => cmd.name));
130
+ for (const cmd of EMBEDDED_SLASH_COMMANDS) {
131
+ const name = cmd.name.replace(/\.md$/, "");
132
+ if (seenNames.has(name)) continue;
133
+
134
+ const { description, body } = parseCommandTemplate(cmd.content);
135
+ fileCommands.push({
136
+ name,
137
+ description,
138
+ content: body,
139
+ source: "bundled",
140
+ });
141
+ seenNames.add(name);
142
+ }
143
+
144
+ return fileCommands;
119
145
  }
120
146
 
121
147
  /**
@@ -2,17 +2,54 @@
2
2
  * Generate session titles using a smol, fast model.
3
3
  */
4
4
 
5
- import type { Model } from "@mariozechner/pi-ai";
5
+ import type { Api, Model } from "@mariozechner/pi-ai";
6
6
  import { completeSimple } from "@mariozechner/pi-ai";
7
7
  import titleSystemPrompt from "../prompts/title-system.md" with { type: "text" };
8
8
  import { logger } from "./logger";
9
9
  import type { ModelRegistry } from "./model-registry";
10
- import { findSmolModel } from "./model-resolver";
10
+ import { parseModelString, SMOL_MODEL_PRIORITY } from "./model-resolver";
11
11
 
12
12
  const TITLE_SYSTEM_PROMPT = titleSystemPrompt;
13
13
 
14
14
  const MAX_INPUT_CHARS = 2000;
15
15
 
16
+ function getTitleModelCandidates(registry: ModelRegistry, savedSmolModel?: string): Model<Api>[] {
17
+ const availableModels = registry.getAvailable();
18
+ if (availableModels.length === 0) return [];
19
+
20
+ const candidates: Model<Api>[] = [];
21
+ const addCandidate = (model?: Model<Api>): void => {
22
+ if (!model) return;
23
+ const exists = candidates.some((candidate) => candidate.provider === model.provider && candidate.id === model.id);
24
+ if (!exists) {
25
+ candidates.push(model);
26
+ }
27
+ };
28
+
29
+ if (savedSmolModel) {
30
+ const parsed = parseModelString(savedSmolModel);
31
+ if (parsed) {
32
+ const match = availableModels.find((model) => model.provider === parsed.provider && model.id === parsed.id);
33
+ addCandidate(match);
34
+ }
35
+ }
36
+
37
+ for (const pattern of SMOL_MODEL_PRIORITY) {
38
+ const needle = pattern.toLowerCase();
39
+ const exactMatch = availableModels.find((model) => model.id.toLowerCase() === needle);
40
+ addCandidate(exactMatch);
41
+
42
+ const fuzzyMatch = availableModels.find((model) => model.id.toLowerCase().includes(needle));
43
+ addCandidate(fuzzyMatch);
44
+ }
45
+
46
+ for (const model of availableModels) {
47
+ addCandidate(model);
48
+ }
49
+
50
+ return candidates;
51
+ }
52
+
16
53
  /**
17
54
  * Find the best available model for title generation.
18
55
  * Uses the configured smol model if set, otherwise auto-discovers using priority chain.
@@ -20,9 +57,9 @@ const MAX_INPUT_CHARS = 2000;
20
57
  * @param registry Model registry
21
58
  * @param savedSmolModel Optional saved smol model from settings (provider/modelId format)
22
59
  */
23
- export async function findTitleModel(registry: ModelRegistry, savedSmolModel?: string): Promise<Model<any> | null> {
24
- const model = await findSmolModel(registry, savedSmolModel);
25
- return model ?? null;
60
+ export async function findTitleModel(registry: ModelRegistry, savedSmolModel?: string): Promise<Model<Api> | null> {
61
+ const candidates = getTitleModelCandidates(registry, savedSmolModel);
62
+ return candidates[0] ?? null;
26
63
  }
27
64
 
28
65
  /**
@@ -37,68 +74,85 @@ export async function generateSessionTitle(
37
74
  registry: ModelRegistry,
38
75
  savedSmolModel?: string,
39
76
  ): Promise<string | null> {
40
- const model = await findTitleModel(registry, savedSmolModel);
41
- if (!model) {
77
+ const candidates = getTitleModelCandidates(registry, savedSmolModel);
78
+ if (candidates.length === 0) {
42
79
  logger.debug("title-generator: no smol model found");
43
80
  return null;
44
81
  }
45
82
 
46
- const apiKey = await registry.getApiKey(model);
47
- if (!apiKey) {
48
- logger.debug("title-generator: no API key for model", { provider: model.provider, id: model.id });
49
- return null;
50
- }
51
-
52
83
  // Truncate message if too long
53
84
  const truncatedMessage =
54
85
  firstMessage.length > MAX_INPUT_CHARS ? `${firstMessage.slice(0, MAX_INPUT_CHARS)}...` : firstMessage;
86
+ const userMessage = `<user-message>\n${truncatedMessage}\n</user-message>`;
55
87
 
56
- const request = {
57
- model: `${model.provider}/${model.id}`,
58
- systemPrompt: TITLE_SYSTEM_PROMPT,
59
- userMessage: `<user-message>\n${truncatedMessage}\n</user-message>`,
60
- maxTokens: 30,
61
- };
62
- logger.debug("title-generator: request", request);
63
-
64
- try {
65
- const response = await completeSimple(
66
- model,
67
- {
68
- systemPrompt: request.systemPrompt,
69
- messages: [{ role: "user", content: request.userMessage, timestamp: Date.now() }],
70
- },
71
- {
72
- apiKey,
73
- maxTokens: 30,
74
- },
75
- );
76
-
77
- // Extract title from response text content
78
- let title = "";
79
- for (const content of response.content) {
80
- if (content.type === "text") {
81
- title += content.text;
82
- }
88
+ for (const model of candidates) {
89
+ const apiKey = await registry.getApiKey(model);
90
+ if (!apiKey) {
91
+ logger.debug("title-generator: no API key for model", { provider: model.provider, id: model.id });
92
+ continue;
83
93
  }
84
- title = title.trim();
85
94
 
86
- logger.debug("title-generator: response", {
87
- title,
88
- usage: response.usage,
89
- stopReason: response.stopReason,
90
- });
95
+ const request = {
96
+ model: `${model.provider}/${model.id}`,
97
+ systemPrompt: TITLE_SYSTEM_PROMPT,
98
+ userMessage,
99
+ maxTokens: 30,
100
+ };
101
+ logger.debug("title-generator: request", request);
91
102
 
92
- if (!title) {
93
- return null;
94
- }
103
+ try {
104
+ const response = await completeSimple(
105
+ model,
106
+ {
107
+ systemPrompt: request.systemPrompt,
108
+ messages: [{ role: "user", content: request.userMessage, timestamp: Date.now() }],
109
+ },
110
+ {
111
+ apiKey,
112
+ maxTokens: 30,
113
+ },
114
+ );
95
115
 
96
- // Clean up: remove quotes, trailing punctuation
97
- return title.replace(/^["']|["']$/g, "").replace(/[.!?]$/, "");
98
- } catch (err) {
99
- logger.debug("title-generator: error", { error: err instanceof Error ? err.message : String(err) });
100
- return null;
116
+ if (response.stopReason === "error") {
117
+ logger.debug("title-generator: response error", {
118
+ model: request.model,
119
+ stopReason: response.stopReason,
120
+ errorMessage: response.errorMessage,
121
+ });
122
+ continue;
123
+ }
124
+
125
+ // Extract title from response text content
126
+ let title = "";
127
+ for (const content of response.content) {
128
+ if (content.type === "text") {
129
+ title += content.text;
130
+ }
131
+ }
132
+ title = title.trim();
133
+
134
+ logger.debug("title-generator: response", {
135
+ model: request.model,
136
+ title,
137
+ usage: response.usage,
138
+ stopReason: response.stopReason,
139
+ });
140
+
141
+ if (!title) {
142
+ continue;
143
+ }
144
+
145
+ // Clean up: remove quotes, trailing punctuation
146
+ return title.replace(/^["']|["']$/g, "").replace(/[.!?]$/, "");
147
+ } catch (err) {
148
+ logger.debug("title-generator: error", {
149
+ model: request.model,
150
+ error: err instanceof Error ? err.message : String(err),
151
+ });
152
+ }
101
153
  }
154
+
155
+ return null;
102
156
  }
103
157
 
104
158
  /**
@@ -17,6 +17,7 @@ export {
17
17
  getLspStatus,
18
18
  type LspServerStatus,
19
19
  type LspToolDetails,
20
+ type LspWarmupOptions,
20
21
  type LspWarmupResult,
21
22
  warmupLspServers,
22
23
  } from "./lsp/index";
@@ -93,6 +94,8 @@ export interface ToolSession {
93
94
  getSessionFile: () => string | null;
94
95
  /** Get session spawns */
95
96
  getSessionSpawns: () => string | null;
97
+ /** Get resolved model string if explicitly set for this session */
98
+ getModelString?: () => string | undefined;
96
99
  /** Settings manager (optional) */
97
100
  settings?: {
98
101
  getImageAutoResize(): boolean;
@@ -148,7 +151,10 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
148
151
 
149
152
  const entries = requestedTools
150
153
  ? requestedTools.filter((name) => name in allTools).map((name) => [name, allTools[name]] as const)
151
- : [...Object.entries(BUILTIN_TOOLS), ...(includeComplete ? ([["complete", HIDDEN_TOOLS.complete]] as const) : [])];
154
+ : [
155
+ ...Object.entries(BUILTIN_TOOLS),
156
+ ...(includeComplete ? ([["complete", HIDDEN_TOOLS.complete]] as const) : []),
157
+ ];
152
158
  const results = await Promise.all(entries.map(([, factory]) => factory(session)));
153
159
  const tools = results.filter((t): t is Tool => t !== null);
154
160
 
@@ -373,10 +373,16 @@ async function sendResponse(
373
373
  // Client Management
374
374
  // =============================================================================
375
375
 
376
+ /** Timeout for warmup initialize requests (5 seconds) */
377
+ export const WARMUP_TIMEOUT_MS = 5000;
378
+
376
379
  /**
377
380
  * Get or create an LSP client for the given server configuration and working directory.
381
+ * @param config - Server configuration
382
+ * @param cwd - Working directory
383
+ * @param initTimeoutMs - Optional timeout for the initialize request (defaults to 30s)
378
384
  */
379
- export async function getOrCreateClient(config: ServerConfig, cwd: string): Promise<LspClient> {
385
+ export async function getOrCreateClient(config: ServerConfig, cwd: string, initTimeoutMs?: number): Promise<LspClient> {
380
386
  const key = `${config.command}:${cwd}`;
381
387
 
382
388
  // Check if client already exists
@@ -430,14 +436,20 @@ export async function getOrCreateClient(config: ServerConfig, cwd: string): Prom
430
436
 
431
437
  try {
432
438
  // Send initialize request
433
- const initResult = (await sendRequest(client, "initialize", {
434
- processId: process.pid,
435
- rootUri: fileToUri(cwd),
436
- rootPath: cwd,
437
- capabilities: CLIENT_CAPABILITIES,
438
- initializationOptions: config.initOptions ?? {},
439
- workspaceFolders: [{ uri: fileToUri(cwd), name: cwd.split("/").pop() ?? "workspace" }],
440
- })) as { capabilities?: unknown };
439
+ const initResult = (await sendRequest(
440
+ client,
441
+ "initialize",
442
+ {
443
+ processId: process.pid,
444
+ rootUri: fileToUri(cwd),
445
+ rootPath: cwd,
446
+ capabilities: CLIENT_CAPABILITIES,
447
+ initializationOptions: config.initOptions ?? {},
448
+ workspaceFolders: [{ uri: fileToUri(cwd), name: cwd.split("/").pop() ?? "workspace" }],
449
+ },
450
+ undefined, // signal
451
+ initTimeoutMs,
452
+ )) as { capabilities?: unknown };
441
453
 
442
454
  if (!initResult) {
443
455
  throw new Error("Failed to initialize LSP: no response");
@@ -662,6 +674,9 @@ export function shutdownClient(key: string): void {
662
674
  // LSP Protocol Methods
663
675
  // =============================================================================
664
676
 
677
+ /** Default timeout for LSP requests (30 seconds) */
678
+ const DEFAULT_REQUEST_TIMEOUT_MS = 30000;
679
+
665
680
  /**
666
681
  * Send an LSP request and wait for response.
667
682
  */
@@ -670,6 +685,7 @@ export async function sendRequest(
670
685
  method: string,
671
686
  params: unknown,
672
687
  signal?: AbortSignal,
688
+ timeoutMs: number = DEFAULT_REQUEST_TIMEOUT_MS,
673
689
  ): Promise<unknown> {
674
690
  // Atomically increment and capture request ID
675
691
  const id = ++client.requestId;
@@ -712,7 +728,7 @@ export async function sendRequest(
712
728
  cleanup();
713
729
  reject(err);
714
730
  }
715
- }, 30000);
731
+ }, timeoutMs);
716
732
  if (signal) {
717
733
  signal.addEventListener("abort", abortHandler, { once: true });
718
734
  if (signal.aborted) {
@@ -19,6 +19,7 @@ import {
19
19
  sendRequest,
20
20
  setIdleTimeout,
21
21
  syncContent,
22
+ WARMUP_TIMEOUT_MS,
22
23
  } from "./client";
23
24
  import { getLinterClient } from "./clients";
24
25
  import { getServersForFile, hasCapability, type LspConfig, loadConfig } from "./config";
@@ -72,23 +73,36 @@ export interface LspWarmupResult {
72
73
  }>;
73
74
  }
74
75
 
76
+ /** Options for warming up LSP servers */
77
+ export interface LspWarmupOptions {
78
+ /** Called when starting to connect to servers */
79
+ onConnecting?: (serverNames: string[]) => void;
80
+ }
81
+
75
82
  /**
76
83
  * Warm up LSP servers for a directory by connecting to all detected servers.
77
84
  * This should be called at startup to avoid cold-start delays.
78
85
  *
79
86
  * @param cwd - Working directory to detect and start servers for
87
+ * @param options - Optional callbacks for progress reporting
80
88
  * @returns Status of each server that was started
81
89
  */
82
- export async function warmupLspServers(cwd: string): Promise<LspWarmupResult> {
90
+ export async function warmupLspServers(cwd: string, options?: LspWarmupOptions): Promise<LspWarmupResult> {
83
91
  const config = await loadConfig(cwd);
84
92
  setIdleTimeout(config.idleTimeoutMs);
85
93
  const servers: LspWarmupResult["servers"] = [];
86
94
  const lspServers = getLspServers(config);
87
95
 
88
- // Start all detected servers in parallel
96
+ // Notify caller which servers we're connecting to
97
+ if (lspServers.length > 0 && options?.onConnecting) {
98
+ options.onConnecting(lspServers.map(([name]) => name));
99
+ }
100
+
101
+ // Start all detected servers in parallel with a short timeout
102
+ // Servers that don't respond quickly will be initialized lazily on first use
89
103
  const results = await Promise.allSettled(
90
104
  lspServers.map(async ([name, serverConfig]) => {
91
- const client = await getOrCreateClient(serverConfig, cwd);
105
+ const client = await getOrCreateClient(serverConfig, cwd, WARMUP_TIMEOUT_MS);
92
106
  return { name, client, fileTypes: serverConfig.fileTypes };
93
107
  }),
94
108
  );
@@ -189,11 +189,46 @@ function parseOutputProvenance(id: string): OutputProvenance | undefined {
189
189
  function extractPreviewLines(content: string, maxLines: number): string[] {
190
190
  const lines = content.split("\n");
191
191
  const preview: string[] = [];
192
+ const structuralTokens = new Set(["{", "}", "[", "]"]);
193
+
194
+ const isStructuralLine = (line: string): boolean => {
195
+ const trimmed = line.trim();
196
+ if (!trimmed) return true;
197
+ const cleaned = trimmed.replace(/,+$/, "");
198
+ return structuralTokens.has(cleaned);
199
+ };
200
+
201
+ const trimmedContent = content.trim();
202
+ const firstMeaningful = lines.find((line) => line.trim());
203
+ if (
204
+ firstMeaningful &&
205
+ isStructuralLine(firstMeaningful) &&
206
+ (trimmedContent.startsWith("{") || trimmedContent.startsWith("[")) &&
207
+ trimmedContent.length <= 200_000
208
+ ) {
209
+ try {
210
+ const parsed = JSON.parse(trimmedContent);
211
+ const minified = JSON.stringify(parsed);
212
+ if (minified) return [minified];
213
+ } catch {
214
+ // Fall back to line-based previews.
215
+ }
216
+ }
217
+
192
218
  for (const line of lines) {
193
- if (!line.trim()) continue;
219
+ if (isStructuralLine(line)) continue;
194
220
  preview.push(line);
195
221
  if (preview.length >= maxLines) break;
196
222
  }
223
+
224
+ if (preview.length === 0) {
225
+ for (const line of lines) {
226
+ if (!line.trim()) continue;
227
+ preview.push(line);
228
+ if (preview.length >= maxLines) break;
229
+ }
230
+ }
231
+
197
232
  return preview;
198
233
  }
199
234
 
@@ -12,13 +12,17 @@ import { loadSync } from "../../../discovery";
12
12
  import architectPlanMd from "../../../prompts/architect-plan.md" with { type: "text" };
13
13
  import implementMd from "../../../prompts/implement.md" with { type: "text" };
14
14
  import implementWithCriticMd from "../../../prompts/implement-with-critic.md" with { type: "text" };
15
+ import initMd from "../../../prompts/init.md" with { type: "text" };
15
16
 
16
17
  const EMBEDDED_COMMANDS: { name: string; content: string }[] = [
17
18
  { name: "architect-plan.md", content: architectPlanMd },
18
19
  { name: "implement-with-critic.md", content: implementWithCriticMd },
19
20
  { name: "implement.md", content: implementMd },
21
+ { name: "init.md", content: initMd },
20
22
  ];
21
23
 
24
+ export const EMBEDDED_COMMAND_TEMPLATES: ReadonlyArray<{ name: string; content: string }> = EMBEDDED_COMMANDS;
25
+
22
26
  /** Workflow command definition */
23
27
  export interface WorkflowCommand {
24
28
  name: string;
@@ -135,6 +135,7 @@ export async function createTaskTool(
135
135
  const startTime = Date.now();
136
136
  const { agents, projectAgentsDir } = await discoverAgents(session.cwd);
137
137
  const { agent: agentName, context, model, output: outputSchema } = params;
138
+ const modelOverride = model ?? session.getModelString?.();
138
139
 
139
140
  // Validate agent exists
140
141
  const agent = getAgent(agents, agentName);
@@ -323,7 +324,7 @@ export async function createTaskTool(
323
324
  toolCount: 0,
324
325
  tokens: 0,
325
326
  durationMs: 0,
326
- modelOverride: model,
327
+ modelOverride,
327
328
  description: t.description,
328
329
  });
329
330
  }
@@ -342,7 +343,7 @@ export async function createTaskTool(
342
343
  index,
343
344
  taskId: task.taskId,
344
345
  context: undefined, // Already prepended above
345
- modelOverride: model,
346
+ modelOverride,
346
347
  outputSchema,
347
348
  sessionFile,
348
349
  persistArtifacts: !!artifactsDir,
@@ -131,7 +131,7 @@ function renderJsonTreeLines(
131
131
  pushLine(`${prefix}${iconArray} ${header}`);
132
132
  if (val.length === 0) {
133
133
  pushLine(
134
- `${buildTreePrefix([...ancestors, !isLast], theme)}${theme.fg("dim", theme.tree.hook)} ${theme.fg(
134
+ `${buildTreePrefix([...ancestors, !isLast], theme)}${theme.fg("dim", theme.tree.last)} ${theme.fg(
135
135
  "dim",
136
136
  "[]",
137
137
  )}`,
@@ -140,7 +140,7 @@ function renderJsonTreeLines(
140
140
  }
141
141
  if (depth >= maxDepth) {
142
142
  pushLine(
143
- `${buildTreePrefix([...ancestors, !isLast], theme)}${theme.fg("dim", theme.tree.hook)} ${theme.fg(
143
+ `${buildTreePrefix([...ancestors, !isLast], theme)}${theme.fg("dim", theme.tree.last)} ${theme.fg(
144
144
  "dim",
145
145
  theme.format.ellipsis,
146
146
  )}`,
@@ -164,7 +164,7 @@ function renderJsonTreeLines(
164
164
  const entries = Object.entries(val as Record<string, unknown>);
165
165
  if (entries.length === 0) {
166
166
  pushLine(
167
- `${buildTreePrefix([...ancestors, !isLast], theme)}${theme.fg("dim", theme.tree.hook)} ${theme.fg(
167
+ `${buildTreePrefix([...ancestors, !isLast], theme)}${theme.fg("dim", theme.tree.last)} ${theme.fg(
168
168
  "dim",
169
169
  "{}",
170
170
  )}`,
@@ -173,7 +173,7 @@ function renderJsonTreeLines(
173
173
  }
174
174
  if (depth >= maxDepth) {
175
175
  pushLine(
176
- `${buildTreePrefix([...ancestors, !isLast], theme)}${theme.fg("dim", theme.tree.hook)} ${theme.fg(
176
+ `${buildTreePrefix([...ancestors, !isLast], theme)}${theme.fg("dim", theme.tree.last)} ${theme.fg(
177
177
  "dim",
178
178
  theme.format.ellipsis,
179
179
  )}`,
@@ -288,10 +288,8 @@ function renderAgentProgress(
288
288
  spinnerFrame?: number,
289
289
  ): string[] {
290
290
  const lines: string[] = [];
291
- const prefix = isLast
292
- ? `${theme.boxSharp.bottomLeft}${theme.boxSharp.horizontal}`
293
- : `${theme.boxSharp.teeRight}${theme.boxSharp.horizontal}`;
294
- const continuePrefix = isLast ? " " : `${theme.boxSharp.vertical} `;
291
+ const prefix = isLast ? theme.tree.last : theme.tree.branch;
292
+ const continuePrefix = isLast ? " " : `${theme.tree.vertical} `;
295
293
 
296
294
  const icon = getStatusIcon(progress.status, theme, spinnerFrame);
297
295
  const iconColor =
@@ -460,10 +458,8 @@ function renderFindings(
460
458
  for (let i = 0; i < displayCount; i++) {
461
459
  const finding = findings[i];
462
460
  const isLastFinding = i === displayCount - 1 && (expanded || findings.length <= 3);
463
- const findingPrefix = isLastFinding
464
- ? `${theme.boxSharp.bottomLeft}${theme.boxSharp.horizontal}`
465
- : `${theme.boxSharp.teeRight}${theme.boxSharp.horizontal}`;
466
- const findingContinue = isLastFinding ? " " : `${theme.boxSharp.vertical} `;
461
+ const findingPrefix = isLastFinding ? theme.tree.last : theme.tree.branch;
462
+ const findingContinue = isLastFinding ? " " : `${theme.tree.vertical} `;
467
463
 
468
464
  const priority = PRIORITY_LABELS[finding.priority] ?? "P?";
469
465
  const color = finding.priority === 0 ? "error" : finding.priority === 1 ? "warning" : "muted";
@@ -496,10 +492,8 @@ function renderFindings(
496
492
  */
497
493
  function renderAgentResult(result: SingleResult, isLast: boolean, expanded: boolean, theme: Theme): string[] {
498
494
  const lines: string[] = [];
499
- const prefix = isLast
500
- ? `${theme.boxSharp.bottomLeft}${theme.boxSharp.horizontal}`
501
- : `${theme.boxSharp.teeRight}${theme.boxSharp.horizontal}`;
502
- const continuePrefix = isLast ? " " : `${theme.boxSharp.vertical} `;
495
+ const prefix = isLast ? theme.tree.last : theme.tree.branch;
496
+ const continuePrefix = isLast ? " " : `${theme.tree.vertical} `;
503
497
 
504
498
  const aborted = result.aborted ?? false;
505
499
  const success = !aborted && result.exitCode === 0;
package/src/main.ts CHANGED
@@ -5,10 +5,10 @@
5
5
  * createAgentSession() options. The SDK does the heavy lifting.
6
6
  */
7
7
 
8
- import { type ImageContent, supportsXhigh } from "@mariozechner/pi-ai";
9
- import chalk from "chalk";
10
8
  import { homedir, tmpdir } from "node:os";
11
9
  import { join, resolve } from "node:path";
10
+ import { type ImageContent, supportsXhigh } from "@mariozechner/pi-ai";
11
+ import chalk from "chalk";
12
12
  import { type Args, parseArgs, printHelp } from "./cli/args";
13
13
  import { processFileArguments } from "./cli/file-processor";
14
14
  import { listModels } from "./cli/list-models";
@@ -421,6 +421,7 @@ export async function main(args: string[]) {
421
421
 
422
422
  const cwd = process.cwd();
423
423
  const settingsManager = SettingsManager.create(cwd);
424
+ settingsManager.applyEnvironmentVariables();
424
425
  time("SettingsManager.create");
425
426
  const { initialMessage, initialImages } = await prepareInitialMessage(parsed, settingsManager.getImageAutoResize());
426
427
  time("prepareInitialMessage");
@@ -27,12 +27,10 @@ export class BashExecutionComponent extends Container {
27
27
  private fullOutputPath?: string;
28
28
  private expanded = false;
29
29
  private contentContainer: Container;
30
- private ui: TUI;
31
30
 
32
31
  constructor(command: string, ui: TUI, excludeFromContext = false) {
33
32
  super();
34
33
  this.command = command;
35
- this.ui = ui;
36
34
 
37
35
  // Use dim border for excluded-from-context commands (!! prefix)
38
36
  const colorKey = excludeFromContext ? "dim" : "bashMode";
@@ -142,15 +140,16 @@ export class BashExecutionComponent extends Container {
142
140
  const displayText = availableLines.map((line) => theme.fg("muted", line)).join("\n");
143
141
  this.contentContainer.addChild(new Text(`\n${displayText}`, 1, 0));
144
142
  } else {
145
- // Use shared visual truncation utility
143
+ // Use shared visual truncation utility, recomputed per render width
146
144
  const styledOutput = previewLogicalLines.map((line) => theme.fg("muted", line)).join("\n");
147
- const { visualLines } = truncateToVisualLines(
148
- `\n${styledOutput}`,
149
- PREVIEW_LINES,
150
- this.ui.terminal.columns,
151
- 1, // padding
152
- );
153
- this.contentContainer.addChild({ render: () => visualLines, invalidate: () => {} });
145
+ const previewText = `\n${styledOutput}`;
146
+ this.contentContainer.addChild({
147
+ render: (width: number) => {
148
+ const { visualLines } = truncateToVisualLines(previewText, PREVIEW_LINES, width, 1);
149
+ return visualLines;
150
+ },
151
+ invalidate: () => {},
152
+ });
154
153
  }
155
154
  }
156
155
 
@@ -437,13 +437,10 @@ class TreeList implements Component {
437
437
 
438
438
  // Build prefix with gutters at their correct positions
439
439
  // Each gutter has a position (displayIndent where its connector was shown)
440
- const connector =
441
- flatNode.showConnector && !flatNode.isVirtualRootChild
442
- ? flatNode.isLast
443
- ? `${theme.boxSharp.bottomLeft}${theme.boxSharp.horizontal} `
444
- : `${theme.boxSharp.teeRight}${theme.boxSharp.horizontal} `
445
- : "";
446
- const connectorPosition = connector ? displayIndent - 1 : -1;
440
+ const hasConnector = flatNode.showConnector && !flatNode.isVirtualRootChild;
441
+ const connectorSymbol = hasConnector ? (flatNode.isLast ? theme.tree.last : theme.tree.branch) : "";
442
+ const connectorChars = hasConnector ? Array.from(connectorSymbol) : [];
443
+ const connectorPosition = hasConnector ? displayIndent - 1 : -1;
447
444
 
448
445
  // Build prefix char by char, placing gutters and connector at their positions
449
446
  const totalChars = displayIndent * 3;
@@ -456,18 +453,18 @@ class TreeList implements Component {
456
453
  const gutter = flatNode.gutters.find((g) => g.position === level);
457
454
  if (gutter) {
458
455
  if (posInLevel === 0) {
459
- prefixChars.push(gutter.show ? theme.boxSharp.vertical : " ");
456
+ prefixChars.push(gutter.show ? theme.tree.vertical : " ");
460
457
  } else {
461
458
  prefixChars.push(" ");
462
459
  }
463
- } else if (connector && level === connectorPosition) {
460
+ } else if (hasConnector && level === connectorPosition) {
464
461
  // Connector at this level
465
462
  if (posInLevel === 0) {
466
- prefixChars.push(flatNode.isLast ? theme.boxSharp.bottomLeft : theme.boxSharp.teeRight);
463
+ prefixChars.push(connectorChars[0] ?? " ");
467
464
  } else if (posInLevel === 1) {
468
- prefixChars.push(theme.boxSharp.horizontal);
465
+ prefixChars.push(connectorChars[1] ?? theme.tree.horizontal);
469
466
  } else {
470
- prefixChars.push(" ");
467
+ prefixChars.push(connectorChars[2] ?? " ");
471
468
  }
472
469
  } else {
473
470
  prefixChars.push(" ");
@@ -1215,6 +1215,9 @@ export class InteractiveMode {
1215
1215
  this.editor.setText("");
1216
1216
  this.updatePendingMessagesDisplay();
1217
1217
  this.ui.requestRender();
1218
+ } else if (event.message.role === "fileMention") {
1219
+ this.addMessageToChat(event.message);
1220
+ this.ui.requestRender();
1218
1221
  } else if (event.message.role === "assistant") {
1219
1222
  this.streamingComponent = new AssistantMessageComponent(undefined, this.hideThinkingBlock);
1220
1223
  this.streamingMessage = event.message;
@@ -1566,7 +1569,7 @@ export class InteractiveMode {
1566
1569
  case "fileMention": {
1567
1570
  // Render compact file mention display
1568
1571
  for (const file of message.files) {
1569
- const text = `${theme.fg("dim", `${theme.tree.hook} `)}${theme.fg("muted", "Read")} ${theme.fg(
1572
+ const text = `${theme.fg("dim", `${theme.tree.last} `)}${theme.fg("muted", "Read")} ${theme.fg(
1570
1573
  "accent",
1571
1574
  file.path,
1572
1575
  )} ${theme.fg("dim", `(${file.lineCount} lines)`)}`;
@@ -1906,31 +1909,6 @@ export class InteractiveMode {
1906
1909
  this.voiceSupervisor.notifyProgress(text);
1907
1910
  }
1908
1911
 
1909
- private async toggleVoiceListening(): Promise<void> {
1910
- if (!this.settingsManager.getVoiceEnabled()) {
1911
- this.settingsManager.setVoiceEnabled(true);
1912
- this.showStatus("Voice mode enabled.");
1913
- }
1914
-
1915
- if (this.voiceAutoModeEnabled) {
1916
- this.voiceAutoModeEnabled = false;
1917
- this.stopVoiceProgressTimer();
1918
- await this.voiceSupervisor.stop();
1919
- this.setVoiceStatus(undefined);
1920
- this.showStatus("Voice mode disabled.");
1921
- return;
1922
- }
1923
-
1924
- this.voiceAutoModeEnabled = true;
1925
- try {
1926
- await this.voiceSupervisor.start();
1927
- } catch (error) {
1928
- this.voiceAutoModeEnabled = false;
1929
- this.setVoiceStatus(undefined);
1930
- this.showError(error instanceof Error ? error.message : String(error));
1931
- }
1932
- }
1933
-
1934
1912
  private async submitVoiceText(text: string): Promise<void> {
1935
1913
  const cleaned = text.trim();
1936
1914
  if (!cleaned) {
@@ -209,8 +209,8 @@ const UNICODE_SYMBOLS: SymbolMap = {
209
209
  "tree.vertical": "│",
210
210
  // pick: ─ | alt: ━ ═ ╌ ┄
211
211
  "tree.horizontal": "─",
212
- // pick: | alt: ╰ ↳
213
- "tree.hook": "",
212
+ // pick: | alt: ╰ ↳
213
+ "tree.hook": "\u2514",
214
214
  // Box Drawing - Rounded
215
215
  // pick: ╭ | alt: ┌ ┏ ╔
216
216
  "boxRound.topLeft": "╭",