@oh-my-pi/pi-coding-agent 11.5.1 → 11.6.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,30 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [11.6.0] - 2026-02-07
6
+
7
+ ### Fixed
8
+
9
+ - Fixed task tool renderer not sanitizing tabs, causing visual holes in TUI output
10
+ - Fixed task tool expanded view showing redundant `<swarm_context>` block that is shared across all tasks
11
+ - Fixed assistant message spacer appearing before tool executions when no visible content follows thinking block
12
+ - Fixed extension runner `emit()` type safety with narrowed event/result types
13
+ - Fixed extension runner `tool_result` event chaining across multiple extensions via dedicated `emitToolResult()`
14
+ - Fixed queued messages not delivered after auto-compaction completes
15
+
16
+ ### Added
17
+
18
+ - Added `/quit` slash command as alias for `/exit`
19
+ - Added per-model overrides (`modelOverrides`) in `models.json` for customizing built-in model properties
20
+ - Added `mergeCustomModels` to merge custom models with built-ins by provider+id instead of replacing
21
+
22
+ ## [11.5.2] - 2026-02-07
23
+
24
+ ### Fixed
25
+
26
+ - Fixed TUI crash when ask tool renders long user input exceeding terminal width by using Text component for word wrapping instead of raw line output
27
+ - Fixed TUI crash when todo_write tool renders long todo content exceeding terminal width by using Text component for word wrapping instead of truncation
28
+
5
29
  ## [11.5.0] - 2026-02-06
6
30
 
7
31
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-coding-agent",
3
- "version": "11.5.1",
3
+ "version": "11.6.0",
4
4
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
5
5
  "type": "module",
6
6
  "ompConfig": {
@@ -90,12 +90,12 @@
90
90
  "@mozilla/readability": "0.6.0",
91
91
  "@oclif/core": "^4.8.0",
92
92
  "@oclif/plugin-autocomplete": "^3.2.40",
93
- "@oh-my-pi/omp-stats": "11.5.1",
94
- "@oh-my-pi/pi-agent-core": "11.5.1",
95
- "@oh-my-pi/pi-ai": "11.5.1",
96
- "@oh-my-pi/pi-natives": "11.5.1",
97
- "@oh-my-pi/pi-tui": "11.5.1",
98
- "@oh-my-pi/pi-utils": "11.5.1",
93
+ "@oh-my-pi/omp-stats": "11.6.0",
94
+ "@oh-my-pi/pi-agent-core": "11.6.0",
95
+ "@oh-my-pi/pi-ai": "11.6.0",
96
+ "@oh-my-pi/pi-natives": "11.6.0",
97
+ "@oh-my-pi/pi-tui": "11.6.0",
98
+ "@oh-my-pi/pi-utils": "11.6.0",
99
99
  "@sinclair/typebox": "^0.34.48",
100
100
  "ajv": "^8.17.1",
101
101
  "chalk": "^5.6.2",
@@ -85,6 +85,27 @@ const ModelDefinitionSchema = Type.Object({
85
85
  compat: Type.Optional(OpenAICompatSchema),
86
86
  });
87
87
 
88
+ // Schema for per-model overrides (all fields optional, merged with built-in model)
89
+ const ModelOverrideSchema = Type.Object({
90
+ name: Type.Optional(Type.String({ minLength: 1 })),
91
+ reasoning: Type.Optional(Type.Boolean()),
92
+ input: Type.Optional(Type.Array(Type.Union([Type.Literal("text"), Type.Literal("image")]))),
93
+ cost: Type.Optional(
94
+ Type.Object({
95
+ input: Type.Optional(Type.Number()),
96
+ output: Type.Optional(Type.Number()),
97
+ cacheRead: Type.Optional(Type.Number()),
98
+ cacheWrite: Type.Optional(Type.Number()),
99
+ }),
100
+ ),
101
+ contextWindow: Type.Optional(Type.Number()),
102
+ maxTokens: Type.Optional(Type.Number()),
103
+ headers: Type.Optional(Type.Record(Type.String(), Type.String())),
104
+ compat: Type.Optional(OpenAICompatSchema),
105
+ });
106
+
107
+ type ModelOverride = Static<typeof ModelOverrideSchema>;
108
+
88
109
  const ProviderConfigSchema = Type.Object({
89
110
  baseUrl: Type.Optional(Type.String({ minLength: 1 })),
90
111
  apiKey: Type.Optional(Type.String({ minLength: 1 })),
@@ -102,6 +123,7 @@ const ProviderConfigSchema = Type.Object({
102
123
  headers: Type.Optional(Type.Record(Type.String(), Type.String())),
103
124
  authHeader: Type.Optional(Type.Boolean()),
104
125
  models: Type.Optional(Type.Array(ModelDefinitionSchema)),
126
+ modelOverrides: Type.Optional(Type.Record(Type.String(), ModelOverrideSchema)),
105
127
  });
106
128
 
107
129
  const ModelsConfigSchema = Type.Object({
@@ -118,11 +140,11 @@ export const ModelsConfigFile = new ConfigFile<ModelsConfig>("models", ModelsCon
118
140
  const models = providerConfig.models ?? [];
119
141
 
120
142
  if (models.length === 0) {
121
- // Override-only config: just needs baseUrl (to override built-in)
122
- if (!providerConfig.baseUrl) {
123
- throw new Error(
124
- `Provider ${providerName}: must specify either "baseUrl" (for override) or "models" (for replacement).`,
125
- );
143
+ // Override-only config: needs baseUrl or modelOverrides
144
+ const hasModelOverrides =
145
+ providerConfig.modelOverrides && Object.keys(providerConfig.modelOverrides).length > 0;
146
+ if (!providerConfig.baseUrl && !hasModelOverrides) {
147
+ throw new Error(`Provider ${providerName}: must specify "baseUrl", "modelOverrides", or "models".`);
126
148
  }
127
149
  } else {
128
150
  // Full replacement: needs baseUrl and apiKey
@@ -172,10 +194,8 @@ export interface SerializedModelRegistry {
172
194
  /** Result of loading custom models from models.json */
173
195
  interface CustomModelsResult {
174
196
  models?: Model<Api>[];
175
- /** Providers with custom models (full replacement) */
176
- replacedProviders?: Set<string>;
177
- /** Providers with only baseUrl/headers override (no custom models) */
178
197
  overrides?: Map<string, ProviderOverride>;
198
+ modelOverrides?: Map<string, Map<string, ModelOverride>>;
179
199
  error?: ConfigError;
180
200
  found: boolean;
181
201
  }
@@ -190,6 +210,45 @@ function resolveApiKeyConfig(keyConfig: string): string | undefined {
190
210
  return keyConfig;
191
211
  }
192
212
 
213
+ function mergeCompat(
214
+ baseCompat: Model<Api>["compat"],
215
+ overrideCompat: ModelOverride["compat"],
216
+ ): Model<Api>["compat"] | undefined {
217
+ if (!overrideCompat) return baseCompat;
218
+ const base = baseCompat as any;
219
+ const override = overrideCompat as any;
220
+ const merged = { ...base, ...override };
221
+ if (base?.openRouterRouting || override.openRouterRouting) {
222
+ merged.openRouterRouting = { ...base?.openRouterRouting, ...override.openRouterRouting };
223
+ }
224
+ if (base?.vercelGatewayRouting || override.vercelGatewayRouting) {
225
+ merged.vercelGatewayRouting = { ...base?.vercelGatewayRouting, ...override.vercelGatewayRouting };
226
+ }
227
+ return merged;
228
+ }
229
+
230
+ function applyModelOverride(model: Model<Api>, override: ModelOverride): Model<Api> {
231
+ const result = { ...model };
232
+ if (override.name !== undefined) result.name = override.name;
233
+ if (override.reasoning !== undefined) result.reasoning = override.reasoning;
234
+ if (override.input !== undefined) result.input = override.input as ("text" | "image")[];
235
+ if (override.contextWindow !== undefined) result.contextWindow = override.contextWindow;
236
+ if (override.maxTokens !== undefined) result.maxTokens = override.maxTokens;
237
+ if (override.cost) {
238
+ result.cost = {
239
+ input: override.cost.input ?? model.cost.input,
240
+ output: override.cost.output ?? model.cost.output,
241
+ cacheRead: override.cost.cacheRead ?? model.cost.cacheRead,
242
+ cacheWrite: override.cost.cacheWrite ?? model.cost.cacheWrite,
243
+ };
244
+ }
245
+ if (override.headers) {
246
+ result.headers = { ...model.headers, ...override.headers };
247
+ }
248
+ result.compat = mergeCompat(model.compat, override.compat);
249
+ return result;
250
+ }
251
+
193
252
  /**
194
253
  * Model registry - loads and manages models, resolves API keys via AuthStorage.
195
254
  */
@@ -272,17 +331,17 @@ export class ModelRegistry {
272
331
  }
273
332
 
274
333
  private loadModels() {
275
- // Load custom models from models.json first (to know which providers to skip/override)
334
+ // Load custom models from models.json first (to know which providers to override)
276
335
  const {
277
336
  models: customModels = [],
278
- replacedProviders = new Set(),
279
337
  overrides = new Map(),
338
+ modelOverrides = new Map(),
280
339
  error: configError,
281
340
  } = this.loadCustomModels();
282
341
  this.configError = configError;
283
342
 
284
- const builtInModels = this.loadBuiltInModels(replacedProviders, overrides);
285
- const combined = [...builtInModels, ...customModels];
343
+ const builtInModels = this.loadBuiltInModels(overrides, modelOverrides);
344
+ const combined = this.mergeCustomModels(builtInModels, customModels);
286
345
 
287
346
  // Update github-copilot base URL based on OAuth credentials
288
347
  const copilotCred = this.authStorage.getOAuthCredential("github-copilot");
@@ -297,56 +356,86 @@ export class ModelRegistry {
297
356
  }
298
357
  }
299
358
 
300
- /** Load built-in models, skipping replaced providers and applying overrides */
301
- private loadBuiltInModels(replacedProviders: Set<string>, overrides: Map<string, ProviderOverride>): Model<Api>[] {
302
- return getProviders()
303
- .filter(provider => !replacedProviders.has(provider))
304
- .flatMap(provider => {
305
- const models = getModels(provider as any) as Model<Api>[];
306
- const override = overrides.get(provider);
307
- if (!override) return models;
308
-
309
- // Apply baseUrl/headers override to all models of this provider
310
- return models.map(m => ({
311
- ...m,
312
- baseUrl: override.baseUrl ?? m.baseUrl,
313
- headers: override.headers ? { ...m.headers, ...override.headers } : m.headers,
314
- }));
359
+ /** Load built-in models, applying provider and per-model overrides */
360
+ private loadBuiltInModels(
361
+ overrides: Map<string, ProviderOverride>,
362
+ modelOverrides: Map<string, Map<string, ModelOverride>>,
363
+ ): Model<Api>[] {
364
+ return getProviders().flatMap(provider => {
365
+ const models = getModels(provider as any) as Model<Api>[];
366
+ const providerOverride = overrides.get(provider);
367
+ const perModelOverrides = modelOverrides.get(provider);
368
+
369
+ return models.map(m => {
370
+ let model = m;
371
+ if (providerOverride) {
372
+ model = {
373
+ ...model,
374
+ baseUrl: providerOverride.baseUrl ?? model.baseUrl,
375
+ headers: providerOverride.headers ? { ...model.headers, ...providerOverride.headers } : model.headers,
376
+ };
377
+ }
378
+ const modelOverride = perModelOverrides?.get(m.id);
379
+ if (modelOverride) {
380
+ model = applyModelOverride(model, modelOverride);
381
+ }
382
+ return model;
315
383
  });
384
+ });
385
+ }
386
+
387
+ /** Merge custom models with built-in, replacing by provider+id match */
388
+ private mergeCustomModels(builtInModels: Model<Api>[], customModels: Model<Api>[]): Model<Api>[] {
389
+ const merged = [...builtInModels];
390
+ for (const customModel of customModels) {
391
+ const existingIndex = merged.findIndex(m => m.provider === customModel.provider && m.id === customModel.id);
392
+ if (existingIndex >= 0) {
393
+ merged[existingIndex] = customModel;
394
+ } else {
395
+ merged.push(customModel);
396
+ }
397
+ }
398
+ return merged;
316
399
  }
317
400
 
318
401
  private loadCustomModels(): CustomModelsResult {
319
402
  const { value, error, status } = this.modelsConfigFile.tryLoad();
320
403
 
321
404
  if (status === "error") {
322
- return { models: [], replacedProviders: new Set(), overrides: new Map(), error, found: true };
405
+ return { models: [], overrides: new Map(), modelOverrides: new Map(), error, found: true };
323
406
  } else if (status === "not-found") {
324
- return { models: [], replacedProviders: new Set(), overrides: new Map(), found: false };
407
+ return { models: [], overrides: new Map(), modelOverrides: new Map(), found: false };
325
408
  }
326
409
 
327
- // Separate providers into "full replacement" (has models) vs "override-only" (no models)
328
- const replacedProviders = new Set<string>();
329
410
  const overrides = new Map<string, ProviderOverride>();
411
+ const allModelOverrides = new Map<string, Map<string, ModelOverride>>();
330
412
 
331
413
  for (const [providerName, providerConfig] of Object.entries(value.providers)) {
332
- if (providerConfig.models && providerConfig.models.length > 0) {
333
- // Has custom models -> full replacement
334
- replacedProviders.add(providerName);
335
- } else {
336
- // No models -> just override baseUrl/headers on built-in
414
+ // Always set overrides when baseUrl/headers present
415
+ if (providerConfig.baseUrl || providerConfig.headers || providerConfig.apiKey) {
337
416
  overrides.set(providerName, {
338
417
  baseUrl: providerConfig.baseUrl,
339
418
  headers: providerConfig.headers,
340
419
  apiKey: providerConfig.apiKey,
341
420
  });
342
- // Store API key for fallback resolver
343
- if (providerConfig.apiKey) {
344
- this.customProviderApiKeys.set(providerName, providerConfig.apiKey);
421
+ }
422
+
423
+ // Always store API key for fallback resolver
424
+ if (providerConfig.apiKey) {
425
+ this.customProviderApiKeys.set(providerName, providerConfig.apiKey);
426
+ }
427
+
428
+ // Parse per-model overrides
429
+ if (providerConfig.modelOverrides) {
430
+ const perModel = new Map<string, ModelOverride>();
431
+ for (const [modelId, override] of Object.entries(providerConfig.modelOverrides)) {
432
+ perModel.set(modelId, override);
345
433
  }
434
+ allModelOverrides.set(providerName, perModel);
346
435
  }
347
436
  }
348
437
 
349
- return { models: this.parseModels(value), replacedProviders, overrides, found: true };
438
+ return { models: this.parseModels(value), overrides, modelOverrides: allModelOverrides, found: true };
350
439
  }
351
440
 
352
441
  private parseModels(config: ModelsConfig): Model<Api>[] {
@@ -34,11 +34,14 @@ import type {
34
34
  RegisteredTool,
35
35
  ResourcesDiscoverEvent,
36
36
  ResourcesDiscoverResult,
37
+ SessionBeforeBranchResult,
37
38
  SessionBeforeCompactResult,
39
+ SessionBeforeSwitchResult,
38
40
  SessionBeforeTreeResult,
39
41
  SessionCompactingResult,
40
42
  ToolCallEvent,
41
43
  ToolCallEventResult,
44
+ ToolResultEvent,
42
45
  ToolResultEventResult,
43
46
  UserBashEvent,
44
47
  UserBashEventResult,
@@ -54,6 +57,44 @@ interface BeforeAgentStartCombinedResult {
54
57
 
55
58
  export type ExtensionErrorListener = (error: ExtensionError) => void;
56
59
 
60
+ /**
61
+ * Events handled by the generic emit() method.
62
+ * Events with dedicated emitXxx() methods are excluded for stronger type safety.
63
+ */
64
+ type RunnerEmitEvent = Exclude<
65
+ ExtensionEvent,
66
+ | ToolCallEvent
67
+ | ToolResultEvent
68
+ | UserBashEvent
69
+ | ContextEvent
70
+ | BeforeAgentStartEvent
71
+ | ResourcesDiscoverEvent
72
+ | InputEvent
73
+ >;
74
+
75
+ type SessionBeforeEvent = Extract<
76
+ RunnerEmitEvent,
77
+ { type: "session_before_switch" | "session_before_branch" | "session_before_compact" | "session_before_tree" }
78
+ >;
79
+
80
+ type SessionBeforeEventResult =
81
+ | SessionBeforeSwitchResult
82
+ | SessionBeforeBranchResult
83
+ | SessionBeforeCompactResult
84
+ | SessionBeforeTreeResult;
85
+
86
+ type RunnerEmitResult<TEvent extends RunnerEmitEvent> = TEvent extends { type: "session_before_switch" }
87
+ ? SessionBeforeSwitchResult | undefined
88
+ : TEvent extends { type: "session_before_branch" }
89
+ ? SessionBeforeBranchResult | undefined
90
+ : TEvent extends { type: "session_before_compact" }
91
+ ? SessionBeforeCompactResult | undefined
92
+ : TEvent extends { type: "session_before_tree" }
93
+ ? SessionBeforeTreeResult | undefined
94
+ : TEvent extends { type: "session.compacting" }
95
+ ? SessionCompactingResult | undefined
96
+ : undefined;
97
+
57
98
  export type NewSessionHandler = (options?: {
58
99
  parentSession?: string;
59
100
  setup?: (sessionManager: SessionManager) => Promise<void>;
@@ -368,29 +409,18 @@ export class ExtensionRunner {
368
409
  };
369
410
  }
370
411
 
371
- private isSessionBeforeEvent(
372
- type: string,
373
- ): type is "session_before_switch" | "session_before_branch" | "session_before_compact" | "session_before_tree" {
412
+ private isSessionBeforeEvent(event: RunnerEmitEvent): event is SessionBeforeEvent {
374
413
  return (
375
- type === "session_before_switch" ||
376
- type === "session_before_branch" ||
377
- type === "session_before_compact" ||
378
- type === "session_before_tree"
414
+ event.type === "session_before_switch" ||
415
+ event.type === "session_before_branch" ||
416
+ event.type === "session_before_compact" ||
417
+ event.type === "session_before_tree"
379
418
  );
380
419
  }
381
420
 
382
- async emit(
383
- event: ExtensionEvent,
384
- ): Promise<
385
- SessionBeforeCompactResult | SessionBeforeTreeResult | SessionCompactingResult | ToolResultEventResult | undefined
386
- > {
421
+ async emit<TEvent extends RunnerEmitEvent>(event: TEvent): Promise<RunnerEmitResult<TEvent>> {
387
422
  const ctx = this.createContext();
388
- let result:
389
- | SessionBeforeCompactResult
390
- | SessionBeforeTreeResult
391
- | SessionCompactingResult
392
- | ToolResultEventResult
393
- | undefined;
423
+ let result: SessionBeforeEventResult | SessionCompactingResult | undefined;
394
424
 
395
425
  for (const ext of this.extensions) {
396
426
  const handlers = ext.handlers.get(event.type);
@@ -400,16 +430,13 @@ export class ExtensionRunner {
400
430
  try {
401
431
  const handlerResult = await handler(event, ctx);
402
432
 
403
- if (this.isSessionBeforeEvent(event.type) && handlerResult) {
404
- result = handlerResult as SessionBeforeCompactResult | SessionBeforeTreeResult;
433
+ if (this.isSessionBeforeEvent(event) && handlerResult) {
434
+ result = handlerResult as SessionBeforeEventResult;
405
435
  if (result.cancel) {
406
- return result;
436
+ return result as RunnerEmitResult<TEvent>;
407
437
  }
408
438
  }
409
439
 
410
- if (event.type === "tool_result" && handlerResult) {
411
- result = handlerResult as ToolResultEventResult;
412
- }
413
440
  if (event.type === "session.compacting" && handlerResult) {
414
441
  result = handlerResult as SessionCompactingResult;
415
442
  }
@@ -426,7 +453,55 @@ export class ExtensionRunner {
426
453
  }
427
454
  }
428
455
 
429
- return result;
456
+ return result as RunnerEmitResult<TEvent>;
457
+ }
458
+
459
+ async emitToolResult(event: ToolResultEvent): Promise<ToolResultEventResult | undefined> {
460
+ const ctx = this.createContext();
461
+ const currentEvent: ToolResultEvent = { ...event };
462
+ let modified = false;
463
+
464
+ for (const ext of this.extensions) {
465
+ const handlers = ext.handlers.get("tool_result");
466
+ if (!handlers || handlers.length === 0) continue;
467
+
468
+ for (const handler of handlers) {
469
+ try {
470
+ const handlerResult = (await handler(currentEvent, ctx)) as ToolResultEventResult | undefined;
471
+ if (!handlerResult) continue;
472
+
473
+ if (handlerResult.content !== undefined) {
474
+ currentEvent.content = handlerResult.content;
475
+ modified = true;
476
+ }
477
+ if (handlerResult.details !== undefined) {
478
+ currentEvent.details = handlerResult.details;
479
+ modified = true;
480
+ }
481
+ if (handlerResult.isError !== undefined) {
482
+ currentEvent.isError = handlerResult.isError;
483
+ modified = true;
484
+ }
485
+ } catch (err) {
486
+ const message = err instanceof Error ? err.message : String(err);
487
+ const stack = err instanceof Error ? err.stack : undefined;
488
+ this.emitError({
489
+ extensionPath: ext.path,
490
+ event: "tool_result",
491
+ error: message,
492
+ stack,
493
+ });
494
+ }
495
+ }
496
+ }
497
+
498
+ if (!modified) return undefined;
499
+
500
+ return {
501
+ content: currentEvent.content,
502
+ details: currentEvent.details,
503
+ isError: currentEvent.isError,
504
+ };
430
505
  }
431
506
 
432
507
  async emitToolCall(event: ToolCallEvent): Promise<ToolCallEventResult | undefined> {
@@ -7,7 +7,7 @@ import type { Static, TSchema } from "@sinclair/typebox";
7
7
  import type { Theme } from "../../modes/theme/theme";
8
8
  import { applyToolProxy } from "../tool-proxy";
9
9
  import type { ExtensionRunner } from "./runner";
10
- import type { RegisteredTool, ToolCallEventResult, ToolResultEventResult } from "./types";
10
+ import type { RegisteredTool, ToolCallEventResult } from "./types";
11
11
 
12
12
  /**
13
13
  * Adapts a RegisteredTool into an AgentTool.
@@ -137,7 +137,7 @@ export class ExtensionToolWrapper<TParameters extends TSchema = TSchema, TDetail
137
137
 
138
138
  // Emit tool_result event - extensions can modify the result and error status
139
139
  if (this.runner.hasHandlers("tool_result")) {
140
- const resultResult = (await this.runner.emit({
140
+ const resultResult = await this.runner.emitToolResult({
141
141
  type: "tool_result",
142
142
  toolName: this.tool.name,
143
143
  toolCallId,
@@ -145,7 +145,7 @@ export class ExtensionToolWrapper<TParameters extends TSchema = TSchema, TDetail
145
145
  content: result.content,
146
146
  details: result.details,
147
147
  isError: !!executionError,
148
- })) as ToolResultEventResult | undefined;
148
+ });
149
149
 
150
150
  if (resultResult) {
151
151
  const modifiedContent: (TextContent | ImageContent)[] = resultResult.content ?? result.content;
@@ -41,6 +41,7 @@ export const BUILTIN_SLASH_COMMANDS: ReadonlyArray<BuiltinSlashCommand> = [
41
41
  { name: "background", description: "Detach UI and continue running in background" },
42
42
  { name: "debug", description: "Write debug log (TUI state and messages)" },
43
43
  { name: "exit", description: "Exit the application" },
44
+ { name: "quit", description: "Quit the application" },
44
45
  ];
45
46
 
46
47
  import { slashCommandCapability } from "../capability/slash-command";
@@ -84,13 +84,16 @@ export class AssistantMessageComponent extends Container {
84
84
  // Set paddingY=0 to avoid extra spacing before tool executions
85
85
  this.contentContainer.addChild(new Markdown(content.text.trim(), 1, 0, getMarkdownTheme()));
86
86
  } else if (content.type === "thinking" && content.thinking.trim()) {
87
- // Check if there's text content after this thinking block
88
- const hasTextAfter = message.content.slice(i + 1).some(c => c.type === "text" && c.text.trim());
87
+ // Add spacing only when another visible assistant content block follows.
88
+ // This avoids a superfluous blank line before separately-rendered tool execution blocks.
89
+ const hasVisibleContentAfter = message.content
90
+ .slice(i + 1)
91
+ .some(c => (c.type === "text" && c.text.trim()) || (c.type === "thinking" && c.thinking.trim()));
89
92
 
90
93
  if (this.hideThinkingBlock) {
91
94
  // Show static "Thinking..." label when hidden
92
95
  this.contentContainer.addChild(new Text(theme.italic(theme.fg("thinkingText", "Thinking...")), 1, 0));
93
- if (hasTextAfter) {
96
+ if (hasVisibleContentAfter) {
94
97
  this.contentContainer.addChild(new Spacer(1));
95
98
  }
96
99
  } else {
@@ -101,7 +104,9 @@ export class AssistantMessageComponent extends Container {
101
104
  italic: true,
102
105
  }),
103
106
  );
104
- this.contentContainer.addChild(new Spacer(1));
107
+ if (hasVisibleContentAfter) {
108
+ this.contentContainer.addChild(new Spacer(1));
109
+ }
105
110
  }
106
111
  }
107
112
  }
@@ -332,7 +332,7 @@ export class InputController {
332
332
  this.ctx.editor.setText("");
333
333
  return;
334
334
  }
335
- if (text === "/exit") {
335
+ if (text === "/quit" || text === "/exit") {
336
336
  this.ctx.editor.setText("");
337
337
  void this.ctx.shutdown();
338
338
  return;
@@ -2884,6 +2884,12 @@ Be thorough - include exact file paths, function names, error messages, and tech
2884
2884
  this.agent.replaceMessages(messages.slice(0, -1));
2885
2885
  }
2886
2886
 
2887
+ setTimeout(() => {
2888
+ this.agent.continue().catch(() => {});
2889
+ }, 100);
2890
+ } else if (this.agent.hasQueuedMessages()) {
2891
+ // Auto-compaction can complete while follow-up/steering/custom messages are waiting.
2892
+ // Kick the loop so queued messages are actually delivered.
2887
2893
  setTimeout(() => {
2888
2894
  this.agent.continue().catch(() => {});
2889
2895
  }, 100);
@@ -15,6 +15,7 @@ import {
15
15
  formatMoreItems,
16
16
  formatStatusIcon,
17
17
  formatTokens,
18
+ replaceTabs,
18
19
  truncateToWidth,
19
20
  } from "../tools/render-utils";
20
21
  import {
@@ -300,7 +301,7 @@ function renderOutputSection(
300
301
  const outputLines = output.trimEnd().split("\n");
301
302
  const previewCount = expanded ? maxExpanded : maxCollapsed;
302
303
  for (const line of outputLines.slice(0, previewCount)) {
303
- lines.push(`${continuePrefix} ${theme.fg("dim", truncateToWidth(line, 70))}`);
304
+ lines.push(`${continuePrefix} ${theme.fg("dim", truncateToWidth(replaceTabs(line), 70))}`);
304
305
  }
305
306
 
306
307
  if (outputLines.length > previewCount) {
@@ -344,7 +345,7 @@ function renderOutputSection(
344
345
  const outputLines = output.trimEnd().split("\n");
345
346
  const previewCount = expanded ? maxExpanded : maxCollapsed;
346
347
  for (const line of outputLines.slice(0, previewCount)) {
347
- lines.push(`${continuePrefix} ${theme.fg("dim", truncateToWidth(line, 70))}`);
348
+ lines.push(`${continuePrefix} ${theme.fg("dim", truncateToWidth(replaceTabs(line), 70))}`);
348
349
  }
349
350
 
350
351
  if (outputLines.length > previewCount) {
@@ -365,10 +366,15 @@ function renderTaskSection(
365
366
  const trimmed = task.trimEnd();
366
367
  if (!expanded || !trimmed) return lines;
367
368
 
369
+ // Strip the shared <swarm_context>...</swarm_context> block — it's the same
370
+ // across all tasks and just adds noise when expanded.
371
+ const stripped = trimmed.replace(/<swarm_context>[\s\S]*?<\/swarm_context>\s*/, "").trimStart();
372
+ if (!stripped) return lines;
373
+
368
374
  lines.push(`${continuePrefix}${theme.fg("dim", "Task")}`);
369
- const taskLines = trimmed.split("\n");
375
+ const taskLines = stripped.split("\n");
370
376
  for (const line of taskLines.slice(0, maxExpanded)) {
371
- lines.push(`${continuePrefix} ${theme.fg("dim", truncateToWidth(line, 70))}`);
377
+ lines.push(`${continuePrefix} ${theme.fg("dim", truncateToWidth(replaceTabs(line), 70))}`);
372
378
  }
373
379
  if (taskLines.length > maxExpanded) {
374
380
  lines.push(`${continuePrefix} ${theme.fg("dim", formatMoreItems(taskLines.length - maxExpanded, "line"))}`);
@@ -454,7 +460,7 @@ export function renderCall(args: TaskParams, theme: Theme): Component {
454
460
  if (hasContext) {
455
461
  lines.push(` ${branch} ${theme.fg("dim", "Context")}`);
456
462
  for (const line of context.split("\n")) {
457
- const content = line ? theme.fg("muted", line) : "";
463
+ const content = line ? theme.fg("muted", replaceTabs(line)) : "";
458
464
  lines.push(` ${vertical} ${content}`);
459
465
  }
460
466
  const taskPrefix = showIsolated ? branch : last;
@@ -635,7 +641,7 @@ function renderReviewResult(
635
641
  lines.push(`${continuePrefix}${theme.fg("dim", "Summary")}`);
636
642
  const explanationLines = summary.explanation.split("\n");
637
643
  for (const line of explanationLines) {
638
- lines.push(`${continuePrefix} ${theme.fg("dim", line)}`);
644
+ lines.push(`${continuePrefix} ${theme.fg("dim", replaceTabs(line))}`);
639
645
  }
640
646
  } else {
641
647
  // Preview: first sentence or ~100 chars
@@ -690,7 +696,7 @@ function renderFindings(
690
696
  // Wrap body text
691
697
  const bodyLines = finding.body.split("\n");
692
698
  for (const bodyLine of bodyLines) {
693
- lines.push(`${continuePrefix}${findingContinue}${theme.fg("dim", bodyLine)}`);
699
+ lines.push(`${continuePrefix}${findingContinue}${theme.fg("dim", replaceTabs(bodyLine))}`);
694
700
  }
695
701
  }
696
702
  }
package/src/tools/ask.ts CHANGED
@@ -454,18 +454,11 @@ export const askToolRenderer = {
454
454
  const txt = result.content[0];
455
455
  const fallback = txt?.type === "text" && txt.text ? txt.text : "";
456
456
  const header = renderStatusLine({ icon: "warning", title: "Ask" }, uiTheme);
457
- const renderedLines = [header, uiTheme.fg("dim", fallback)];
458
- return {
459
- render() {
460
- return renderedLines;
461
- },
462
- invalidate() {},
463
- };
457
+ return new Text(`${header}\n${uiTheme.fg("dim", fallback)}`, 0, 0);
464
458
  }
465
459
 
466
460
  // Multi-part results
467
461
  if (details.results && details.results.length > 0) {
468
- const lines: string[] = [];
469
462
  const hasAnySelection = details.results.some(
470
463
  r => r.customInput || (r.selectedOptions && r.selectedOptions.length > 0),
471
464
  );
@@ -477,7 +470,7 @@ export const askToolRenderer = {
477
470
  },
478
471
  uiTheme,
479
472
  );
480
- lines.push(header);
473
+ let text = header;
481
474
 
482
475
  for (let i = 0; i < details.results.length; i++) {
483
476
  const r = details.results[i];
@@ -489,47 +482,29 @@ export const askToolRenderer = {
489
482
  ? uiTheme.styledSymbol("status.success", "success")
490
483
  : uiTheme.styledSymbol("status.warning", "warning");
491
484
 
492
- lines.push(
493
- ` ${uiTheme.fg("dim", branch)} ${statusIcon} ${uiTheme.fg("dim", `[${r.id}]`)} ${uiTheme.fg("accent", r.question)}`,
494
- );
485
+ text += `\n ${uiTheme.fg("dim", branch)} ${statusIcon} ${uiTheme.fg("dim", `[${r.id}]`)} ${uiTheme.fg("accent", r.question)}`;
495
486
 
496
487
  if (r.customInput) {
497
- lines.push(
498
- `${continuation}${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.success", "success")} ${uiTheme.fg("toolOutput", r.customInput)}`,
499
- );
488
+ text += `\n${continuation}${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.success", "success")} ${uiTheme.fg("toolOutput", r.customInput)}`;
500
489
  } else if (r.selectedOptions.length > 0) {
501
490
  for (let j = 0; j < r.selectedOptions.length; j++) {
502
491
  const isLast = j === r.selectedOptions.length - 1;
503
492
  const optBranch = isLast ? uiTheme.tree.last : uiTheme.tree.branch;
504
- lines.push(
505
- `${continuation}${uiTheme.fg("dim", optBranch)} ${uiTheme.fg("success", uiTheme.checkbox.checked)} ${uiTheme.fg("toolOutput", r.selectedOptions[j])}`,
506
- );
493
+ text += `\n${continuation}${uiTheme.fg("dim", optBranch)} ${uiTheme.fg("success", uiTheme.checkbox.checked)} ${uiTheme.fg("toolOutput", r.selectedOptions[j])}`;
507
494
  }
508
495
  } else {
509
- lines.push(
510
- `${continuation}${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.warning", "warning")} ${uiTheme.fg("warning", "Cancelled")}`,
511
- );
496
+ text += `\n${continuation}${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.warning", "warning")} ${uiTheme.fg("warning", "Cancelled")}`;
512
497
  }
513
498
  }
514
499
 
515
- return {
516
- render() {
517
- return lines;
518
- },
519
- invalidate() {},
520
- };
500
+ return new Text(text, 0, 0);
521
501
  }
522
502
 
523
503
  // Single question result
524
504
  if (!details.question) {
525
505
  const txt = result.content[0];
526
- const renderedLines = txt?.type === "text" && txt.text ? txt.text.split("\n") : [""];
527
- return {
528
- render() {
529
- return renderedLines;
530
- },
531
- invalidate() {},
532
- };
506
+ const fallback = txt?.type === "text" && txt.text ? txt.text : "";
507
+ return new Text(fallback, 0, 0);
533
508
  }
534
509
 
535
510
  const hasSelection = details.customInput || (details.selectedOptions && details.selectedOptions.length > 0);
@@ -552,12 +527,6 @@ export const askToolRenderer = {
552
527
  text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.warning", "warning")} ${uiTheme.fg("warning", "Cancelled")}`;
553
528
  }
554
529
 
555
- const renderedLines = text.split("\n");
556
- return {
557
- render() {
558
- return renderedLines;
559
- },
560
- invalidate() {},
561
- };
530
+ return new Text(text, 0, 0);
562
531
  },
563
532
  };
@@ -11,7 +11,7 @@ import type { RenderResultOptions } from "../extensibility/custom-tools/types";
11
11
  import type { Theme } from "../modes/theme/theme";
12
12
  import todoWriteDescription from "../prompts/tools/todo-write.md" with { type: "text" };
13
13
  import type { ToolSession } from "../sdk";
14
- import { Ellipsis, Hasher, type RenderCache, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
14
+ import { renderStatusLine, renderTreeList } from "../tui";
15
15
  import { PREVIEW_LIMITS } from "./render-utils";
16
16
 
17
17
  const todoWriteSchema = Type.Object({
@@ -234,39 +234,22 @@ export const todoWriteToolRenderer = {
234
234
  );
235
235
  if (todos.length === 0) {
236
236
  const fallback = result.content?.find(c => c.type === "text")?.text ?? "No todos";
237
- const renderedLines = [header, uiTheme.fg("dim", fallback)];
238
- return {
239
- render() {
240
- return renderedLines;
241
- },
242
- invalidate() {},
243
- };
237
+ return new Text(`${header}\n${uiTheme.fg("dim", fallback)}`, 0, 0);
244
238
  }
245
- let cached: RenderCache | undefined;
246
239
 
247
- return {
248
- render(width) {
249
- const { expanded } = options;
250
- const key = new Hasher().bool(expanded).u32(width).digest();
251
- if (cached?.key === key) return cached.lines;
252
- const treeLines = renderTreeList(
253
- {
254
- items: todos,
255
- expanded,
256
- maxCollapsed: PREVIEW_LIMITS.COLLAPSED_ITEMS,
257
- itemType: "todo",
258
- renderItem: todo => formatTodoLine(todo, uiTheme, ""),
259
- },
260
- uiTheme,
261
- );
262
- const lines = [header, ...treeLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
263
- cached = { key, lines };
264
- return lines;
265
- },
266
- invalidate() {
267
- cached = undefined;
240
+ const { expanded } = options;
241
+ const treeLines = renderTreeList(
242
+ {
243
+ items: todos,
244
+ expanded,
245
+ maxCollapsed: PREVIEW_LIMITS.COLLAPSED_ITEMS,
246
+ itemType: "todo",
247
+ renderItem: todo => formatTodoLine(todo, uiTheme, ""),
268
248
  },
269
- };
249
+ uiTheme,
250
+ );
251
+ const text = [header, ...treeLines].join("\n");
252
+ return new Text(text, 0, 0);
270
253
  },
271
254
  mergeCallAndResult: true,
272
255
  };