@oh-my-pi/pi-coding-agent 15.2.4 → 15.3.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.
Files changed (50) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/types/config/settings-schema.d.ts +34 -1
  3. package/dist/types/config/settings.d.ts +6 -0
  4. package/dist/types/discovery/helpers.d.ts +1 -0
  5. package/dist/types/goals/runtime.d.ts +4 -0
  6. package/dist/types/modes/components/status-line/types.d.ts +10 -0
  7. package/dist/types/modes/components/status-line.d.ts +10 -0
  8. package/dist/types/modes/interactive-mode.d.ts +3 -1
  9. package/dist/types/modes/types.d.ts +3 -1
  10. package/dist/types/modes/utils/context-usage.d.ts +17 -0
  11. package/dist/types/modes/utils/ui-helpers.d.ts +5 -1
  12. package/dist/types/session/agent-session.d.ts +9 -0
  13. package/dist/types/task/executor.d.ts +3 -1
  14. package/dist/types/task/types.d.ts +35 -0
  15. package/dist/types/tools/bash-command-fixup.d.ts +0 -5
  16. package/dist/types/utils/clipboard.d.ts +3 -1
  17. package/dist/types/utils/image-resize.d.ts +4 -1
  18. package/package.json +7 -7
  19. package/src/config/settings-schema.ts +29 -1
  20. package/src/config/settings.ts +19 -0
  21. package/src/discovery/helpers.ts +5 -1
  22. package/src/extensibility/plugins/legacy-pi-compat.ts +27 -5
  23. package/src/goals/runtime.ts +35 -13
  24. package/src/main.ts +1 -1
  25. package/src/modes/components/model-selector.ts +53 -22
  26. package/src/modes/components/status-line/segments.ts +53 -0
  27. package/src/modes/components/status-line/types.ts +4 -0
  28. package/src/modes/components/status-line.ts +147 -12
  29. package/src/modes/controllers/command-controller.ts +9 -0
  30. package/src/modes/controllers/event-controller.ts +8 -0
  31. package/src/modes/interactive-mode.ts +23 -8
  32. package/src/modes/theme/theme.ts +1 -1
  33. package/src/modes/types.ts +1 -1
  34. package/src/modes/utils/context-usage.ts +25 -2
  35. package/src/modes/utils/ui-helpers.ts +11 -1
  36. package/src/prompts/agents/frontmatter.md +1 -0
  37. package/src/sdk.ts +24 -0
  38. package/src/session/agent-session.ts +58 -0
  39. package/src/session/session-manager.ts +54 -1
  40. package/src/slash-commands/builtin-registry.ts +10 -0
  41. package/src/task/executor.ts +50 -1
  42. package/src/task/index.ts +11 -0
  43. package/src/task/render.ts +26 -2
  44. package/src/task/types.ts +35 -0
  45. package/src/tools/bash-command-fixup.ts +0 -10
  46. package/src/tools/bash.ts +1 -9
  47. package/src/utils/clipboard.ts +68 -3
  48. package/src/utils/image-resize.ts +51 -26
  49. package/dist/types/modes/components/status-line-segment-editor.d.ts +0 -24
  50. package/src/modes/components/status-line-segment-editor.ts +0 -359
package/src/main.ts CHANGED
@@ -281,7 +281,7 @@ async function runInteractiveMode(
281
281
  })
282
282
  .catch(() => {});
283
283
 
284
- mode.renderInitialMessages();
284
+ mode.renderInitialMessages(undefined, { preserveExistingChat: true });
285
285
 
286
286
  for (const notify of notifs) {
287
287
  if (!notify) {
@@ -92,9 +92,26 @@ interface MenuRoleAction {
92
92
  role: string; // now accepts custom role strings
93
93
  }
94
94
 
95
+ interface ProviderTabState {
96
+ id: string;
97
+ label: string;
98
+ providerId?: string;
99
+ }
95
100
  const ALL_TAB = "ALL";
96
101
  const CANONICAL_TAB = "CANONICAL";
97
102
 
103
+ const STATIC_PROVIDER_TABS: ProviderTabState[] = [
104
+ { id: ALL_TAB, label: ALL_TAB },
105
+ { id: CANONICAL_TAB, label: CANONICAL_TAB },
106
+ ];
107
+
108
+ function formatProviderTabLabel(providerId: string): string {
109
+ return providerId.replace(/[-_]+/g, " ").toUpperCase();
110
+ }
111
+
112
+ function createProviderTab(providerId: string): ProviderTabState {
113
+ return { id: providerId, label: formatProviderTabLabel(providerId), providerId };
114
+ }
98
115
  /**
99
116
  * Component that renders a model selector with provider tabs and context menu.
100
117
  * - Tab/Arrow Left/Right: Switch between provider tabs
@@ -126,7 +143,7 @@ export class ModelSelectorComponent extends Container {
126
143
  #menuRoleActions: MenuRoleAction[] = [];
127
144
 
128
145
  // Tab state
129
- #providers: string[] = [ALL_TAB];
146
+ #providers: ProviderTabState[] = STATIC_PROVIDER_TABS;
130
147
  #activeTabIndex: number = 0;
131
148
 
132
149
  // Context menu state
@@ -432,23 +449,29 @@ export class ModelSelectorComponent extends Container {
432
449
  }
433
450
 
434
451
  #buildProviderTabs(): void {
452
+ const activeTabId = this.#getActiveTab().id;
435
453
  const providerSet = new Set<string>();
436
454
  for (const item of this.#allModels) {
437
- providerSet.add(item.provider.toUpperCase());
455
+ providerSet.add(item.provider);
438
456
  }
439
457
  for (const provider of this.#modelRegistry.getDiscoverableProviders()) {
440
- providerSet.add(provider.toUpperCase());
458
+ providerSet.add(provider);
441
459
  }
442
- const sortedProviders = Array.from(providerSet).sort();
443
- this.#providers = [ALL_TAB, CANONICAL_TAB, ...sortedProviders];
460
+ const sortedProviderIds = Array.from(providerSet).sort((left, right) =>
461
+ formatProviderTabLabel(left).localeCompare(formatProviderTabLabel(right)),
462
+ );
463
+ this.#providers = [...STATIC_PROVIDER_TABS, ...sortedProviderIds.map(createProviderTab)];
464
+ const activeIndex = this.#providers.findIndex(tab => tab.id === activeTabId);
465
+ this.#activeTabIndex =
466
+ activeIndex >= 0 ? activeIndex : Math.min(this.#activeTabIndex, this.#providers.length - 1);
444
467
  }
445
468
 
446
469
  async #refreshSelectedProvider(): Promise<void> {
447
- const activeProvider = this.#getActiveProvider();
448
- if (this.#scopedModels.length > 0 || activeProvider === ALL_TAB || activeProvider === CANONICAL_TAB) {
470
+ const providerId = this.#getActiveProviderId();
471
+ if (this.#scopedModels.length > 0 || !providerId) {
449
472
  return;
450
473
  }
451
- await this.#modelRegistry.refreshProvider(activeProvider.toLowerCase());
474
+ await this.#modelRegistry.refreshProvider(providerId);
452
475
  await this.#loadModels();
453
476
  this.#buildProviderTabs();
454
477
  this.#updateTabBar();
@@ -459,7 +482,7 @@ export class ModelSelectorComponent extends Container {
459
482
  #updateTabBar(): void {
460
483
  this.#headerContainer.clear();
461
484
 
462
- const tabs: Tab[] = this.#providers.map(provider => ({ id: provider, label: provider }));
485
+ const tabs: Tab[] = this.#providers.map(provider => ({ id: provider.id, label: provider.label }));
463
486
  const tabBar = new TabBar("Models", tabs, getTabBarTheme(), this.#activeTabIndex);
464
487
  tabBar.onTabChange = (_tab, index) => {
465
488
  this.#activeTabIndex = index;
@@ -475,29 +498,38 @@ export class ModelSelectorComponent extends Container {
475
498
  this.#headerContainer.addChild(tabBar);
476
499
  }
477
500
 
478
- #getActiveProvider(): string {
479
- return this.#providers[this.#activeTabIndex] ?? ALL_TAB;
501
+ #getActiveTab(): ProviderTabState {
502
+ return this.#providers[this.#activeTabIndex] ?? STATIC_PROVIDER_TABS[0]!;
503
+ }
504
+
505
+ #getActiveTabId(): string {
506
+ return this.#getActiveTab().id;
507
+ }
508
+
509
+ #getActiveProviderId(): string | undefined {
510
+ return this.#getActiveTab().providerId;
480
511
  }
481
512
 
482
513
  #isCanonicalTab(): boolean {
483
- return this.#getActiveProvider() === CANONICAL_TAB;
514
+ return this.#getActiveTabId() === CANONICAL_TAB;
484
515
  }
485
516
 
486
517
  #filterModels(query: string): void {
487
- const activeProvider = this.#getActiveProvider();
488
- const isCanonicalTab = activeProvider === CANONICAL_TAB;
518
+ const activeTabId = this.#getActiveTabId();
519
+ const activeProviderId = this.#getActiveProviderId();
520
+ const isCanonicalTab = activeTabId === CANONICAL_TAB;
489
521
 
490
522
  // Start with all models or filter by provider/canonical view
491
523
  let baseModels = this.#allModels;
492
524
  const baseCanonicalModels = this.#canonicalModels;
493
- if (!isCanonicalTab && activeProvider !== ALL_TAB) {
494
- baseModels = this.#allModels.filter(m => m.provider.toUpperCase() === activeProvider);
525
+ if (activeProviderId) {
526
+ baseModels = this.#allModels.filter(m => m.provider === activeProviderId);
495
527
  }
496
528
 
497
529
  // Apply fuzzy filter if query is present
498
530
  if (query.trim()) {
499
531
  // If user is searching from a provider tab, auto-switch to ALL to show global provider results.
500
- if (activeProvider !== ALL_TAB && !isCanonicalTab) {
532
+ if (activeProviderId && !isCanonicalTab) {
501
533
  this.#activeTabIndex = 0;
502
534
  if (this.#tabBar && this.#tabBar.getActiveIndex() !== 0) {
503
535
  this.#tabBar.setActiveIndex(0);
@@ -577,11 +609,11 @@ export class ModelSelectorComponent extends Container {
577
609
  }
578
610
 
579
611
  #getProviderEmptyStateMessage(): string | undefined {
580
- const activeProvider = this.#getActiveProvider();
581
- if (activeProvider === ALL_TAB || activeProvider === CANONICAL_TAB || this.#searchInput.getValue().trim()) {
612
+ const activeProviderId = this.#getActiveProviderId();
613
+ if (!activeProviderId || this.#searchInput.getValue().trim()) {
582
614
  return undefined;
583
615
  }
584
- const state = this.#modelRegistry.getProviderDiscoveryState(activeProvider.toLowerCase());
616
+ const state = this.#modelRegistry.getProviderDiscoveryState(activeProviderId);
585
617
  if (!state) {
586
618
  return undefined;
587
619
  }
@@ -619,8 +651,7 @@ export class ModelSelectorComponent extends Container {
619
651
  );
620
652
  const endIndex = Math.min(startIndex + maxVisible, visibleItems.length);
621
653
 
622
- const activeProvider = this.#getActiveProvider();
623
- const showProvider = activeProvider === ALL_TAB;
654
+ const showProvider = this.#getActiveTabId() === ALL_TAB;
624
655
 
625
656
  // Show visible slice of filtered models
626
657
  for (let i = startIndex; i < endIndex; i++) {
@@ -460,6 +460,58 @@ const sessionNameSegment: StatusLineSegment = {
460
460
  },
461
461
  };
462
462
 
463
+ function pickUsageColor(percent: number): "muted" | "warning" | "error" {
464
+ if (percent >= 80) return "error";
465
+ if (percent >= 50) return "warning";
466
+ return "muted";
467
+ }
468
+
469
+ function formatUsageReset(value: number, unit: "m" | "h"): string {
470
+ if (unit === "m") {
471
+ // total minutes (5h window: max 300)
472
+ if (value < 60) return `${value}m`;
473
+ const hours = Math.floor(value / 60);
474
+ const mins = value % 60;
475
+ return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
476
+ }
477
+ // total hours (7d window: max 168)
478
+ if (value < 24) return `${value}h`;
479
+ const days = Math.floor(value / 24);
480
+ const hours = value % 24;
481
+ return hours > 0 ? `${days}d ${hours}h` : `${days}d`;
482
+ }
483
+
484
+ const usageSegment: StatusLineSegment = {
485
+ id: "usage",
486
+ render(ctx) {
487
+ const u = ctx.usage;
488
+ if (!u || (!u.fiveHour && !u.sevenDay)) {
489
+ return { content: "", visible: false };
490
+ }
491
+ const parts: string[] = [];
492
+ if (u.fiveHour) {
493
+ const pct = u.fiveHour.percent;
494
+ const pctText = theme.fg(pickUsageColor(pct), `${Math.round(pct)}%`);
495
+ const reset =
496
+ u.fiveHour.resetMinutes !== undefined
497
+ ? theme.fg("muted", ` (${formatUsageReset(u.fiveHour.resetMinutes, "m")})`)
498
+ : "";
499
+ parts.push(`5h ${pctText}${reset}`);
500
+ }
501
+ if (u.sevenDay) {
502
+ const pct = u.sevenDay.percent;
503
+ const pctText = theme.fg(pickUsageColor(pct), `${Math.round(pct)}%`);
504
+ const reset =
505
+ u.sevenDay.resetHours !== undefined
506
+ ? theme.fg("muted", ` (${formatUsageReset(u.sevenDay.resetHours, "h")})`)
507
+ : "";
508
+ parts.push(`7d ${pctText}${reset}`);
509
+ }
510
+ const content = withIcon(theme.icon.time, parts.join(theme.sep.dot));
511
+ return { content, visible: true };
512
+ },
513
+ };
514
+
463
515
  // ═══════════════════════════════════════════════════════════════════════════
464
516
  // Segment Registry
465
517
  // ═══════════════════════════════════════════════════════════════════════════
@@ -486,6 +538,7 @@ export const SEGMENTS: Record<StatusLineSegmentId, StatusLineSegment> = {
486
538
  cache_read: cacheReadSegment,
487
539
  cache_write: cacheWriteSegment,
488
540
  session_name: sessionNameSegment,
541
+ usage: usageSegment,
489
542
  };
490
543
 
491
544
  export function renderSegment(id: StatusLineSegmentId, ctx: SegmentContext): RenderedSegment {
@@ -51,6 +51,10 @@ export interface SegmentContext {
51
51
  status: { staged: number; unstaged: number; untracked: number } | null;
52
52
  pr: { number: number; url: string } | null;
53
53
  };
54
+ usage: {
55
+ fiveHour?: { percent: number; resetMinutes?: number };
56
+ sevenDay?: { percent: number; resetHours?: number };
57
+ } | null;
54
58
  }
55
59
 
56
60
  export interface RenderedSegment {
@@ -1,4 +1,5 @@
1
1
  import * as fs from "node:fs";
2
+ import { estimateTokens } from "@oh-my-pi/pi-agent-core/compaction";
2
3
  import { type Component, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
3
4
  import { formatCount, getProjectDir } from "@oh-my-pi/pi-utils";
4
5
  import { $ } from "bun";
@@ -9,7 +10,7 @@ import type { AgentSession } from "../../session/agent-session";
9
10
  import * as git from "../../utils/git";
10
11
  import { getSessionAccentAnsi, getSessionAccentHex } from "../../utils/session-color";
11
12
  import { sanitizeStatusText } from "../shared";
12
- import { computeContextBreakdown } from "../utils/context-usage";
13
+ import { computeNonMessageTokens } from "../utils/context-usage";
13
14
  import {
14
15
  canReuseCachedPr,
15
16
  createPrCacheContext,
@@ -73,9 +74,30 @@ export class StatusLineComponent implements Component {
73
74
  #lastTokensPerSecond: number | null = null;
74
75
  #lastTokensPerSecondTimestamp: number | null = null;
75
76
 
76
- // Context breakdown caching (2s TTL — aligns with /context command output)
77
+ // Anthropic usage caching (5-min TTL, OAuth/sub only)
78
+ #cachedUsage: {
79
+ fiveHour?: { percent: number; resetMinutes?: number };
80
+ sevenDay?: { percent: number; resetHours?: number };
81
+ } | null = null;
82
+ #usageFetchedAt = 0;
83
+ #usageInFlight = false;
84
+ // Context breakdown — incremental cache. Replaces the previous 2-second
85
+ // TTL design (which re-walked every message on each refresh and produced
86
+ // ~1.1 s sync freezes on 2,000+ message sessions because `updateEditorTopBorder`
87
+ // is called on every agent event in event-controller). The new scheme
88
+ // exploits the fact that `session.messages` is append-only during a turn
89
+ // and only shrinks on compaction.
77
90
  #cachedBreakdown: { usedTokens: number; contextWindow: number } | null = null;
78
- #breakdownFetchedAt = 0;
91
+ // Per-message token counts indexed by `session.messages` position. Entries
92
+ // here are immutable: a message at index `i` is finalized (its content
93
+ // no longer mutates) once index `i+1` exists. We therefore cache all but
94
+ // the LAST message (which may still be growing during streaming).
95
+ #messageTokenCache: number[] = [];
96
+ // Cached non-message total (system prompt + tools + skills). Invalidated
97
+ // when the inputs-identity fingerprint changes (model swap, skill toggle,
98
+ // tool registration).
99
+ #nonMessageTokensCache: number | undefined;
100
+ #nonMessageInputsKey: string | undefined;
79
101
 
80
102
  constructor(private readonly session: AgentSession) {
81
103
  this.#settings = {
@@ -309,22 +331,134 @@ export class StatusLineComponent implements Component {
309
331
  return null;
310
332
  }
311
333
 
312
- #getCachedContextBreakdown(): { usedTokens: number; contextWindow: number } {
334
+ #refreshUsageInBackground(): void {
313
335
  const now = Date.now();
314
- if (!this.#cachedBreakdown || now - this.#breakdownFetchedAt > 2_000) {
315
- const breakdown = computeContextBreakdown(this.session);
316
- this.#cachedBreakdown = {
317
- usedTokens: breakdown.usedTokens,
318
- contextWindow: breakdown.contextWindow,
319
- };
320
- this.#breakdownFetchedAt = now;
336
+ if (this.#usageInFlight) return;
337
+ if (this.#usageFetchedAt > 0 && now - this.#usageFetchedAt < 5 * 60_000) return;
338
+ const fetcher = (this.session as { fetchUsageReports?: () => Promise<unknown> }).fetchUsageReports;
339
+ if (typeof fetcher !== "function") return;
340
+ this.#usageInFlight = true;
341
+ void fetcher
342
+ .call(this.session)
343
+ .then(reports => {
344
+ this.#cachedUsage = this.#normalizeUsageReports(reports);
345
+ this.#usageFetchedAt = Date.now();
346
+ })
347
+ .catch(() => {
348
+ /* keep last known data on error */
349
+ })
350
+ .finally(() => {
351
+ this.#usageInFlight = false;
352
+ });
353
+ }
354
+
355
+ #normalizeUsageReports(reports: unknown): {
356
+ fiveHour?: { percent: number; resetMinutes?: number };
357
+ sevenDay?: { percent: number; resetHours?: number };
358
+ } | null {
359
+ if (!Array.isArray(reports)) return null;
360
+ let fiveHour: { percent: number; resetMinutes?: number } | undefined;
361
+ let sevenDay: { percent: number; resetHours?: number } | undefined;
362
+ const now = Date.now();
363
+ for (const report of reports) {
364
+ if (!report || typeof report !== "object") continue;
365
+ const limits = (report as { limits?: unknown }).limits;
366
+ if (!Array.isArray(limits)) continue;
367
+ for (const limit of limits) {
368
+ if (!limit || typeof limit !== "object") continue;
369
+ const l = limit as {
370
+ scope?: { windowId?: string; tier?: string };
371
+ window?: { resetsAt?: number };
372
+ amount?: { usedFraction?: number };
373
+ };
374
+ const fraction = l.amount?.usedFraction;
375
+ if (typeof fraction !== "number") continue;
376
+ const windowId = l.scope?.windowId;
377
+ const tier = l.scope?.tier;
378
+ const resetsAt = l.window?.resetsAt;
379
+ if (windowId === "5h" && !tier && !fiveHour) {
380
+ fiveHour = {
381
+ percent: fraction * 100,
382
+ resetMinutes:
383
+ typeof resetsAt === "number" ? Math.max(0, Math.round((resetsAt - now) / 60_000)) : undefined,
384
+ };
385
+ } else if (windowId === "7d" && !tier && !sevenDay) {
386
+ sevenDay = {
387
+ percent: fraction * 100,
388
+ resetHours:
389
+ typeof resetsAt === "number" ? Math.max(0, Math.round((resetsAt - now) / 3_600_000)) : undefined,
390
+ };
391
+ }
392
+ }
393
+ }
394
+ if (!fiveHour && !sevenDay) return null;
395
+ return { fiveHour, sevenDay };
396
+ }
397
+
398
+ /**
399
+ * Compute the (cached) used-tokens / context-window totals for the
400
+ * status-line context% segment. Exposed (non-private) so unit tests can
401
+ * verify the incremental-cache invariants; not part of any external
402
+ * API.
403
+ */
404
+ getCachedContextBreakdown(): { usedTokens: number; contextWindow: number } {
405
+ const messages = this.session.messages ?? [];
406
+ const contextWindow = this.session.model?.contextWindow ?? 0;
407
+
408
+ // 1) Non-message tokens (system prompt + tools + skills). Refresh only
409
+ // when the inputs identity fingerprint changes — usually never
410
+ // during a streaming turn. ~10-30 ms when it does refresh.
411
+ const inputsKey = this.#computeNonMessageInputsKey();
412
+ if (this.#nonMessageTokensCache === undefined || this.#nonMessageInputsKey !== inputsKey) {
413
+ this.#nonMessageTokensCache = computeNonMessageTokens(this.session);
414
+ this.#nonMessageInputsKey = inputsKey;
415
+ }
416
+
417
+ // 2) Message tokens — incremental.
418
+ // Compaction handling: if messages.length shrank, the array was
419
+ // truncated. Reset cache; the next iteration rebuilds from scratch.
420
+ if (this.#messageTokenCache.length > Math.max(0, messages.length - 1)) {
421
+ this.#messageTokenCache.length = 0;
422
+ }
423
+ // Cache all but the last message. The last message may still be
424
+ // growing during streaming (assistant delta blocks append to the
425
+ // existing message); recomputing it each refresh is one
426
+ // `estimateTokens` call (~0.5 ms) and stays correct.
427
+ while (this.#messageTokenCache.length < Math.max(0, messages.length - 1)) {
428
+ const idx = this.#messageTokenCache.length;
429
+ this.#messageTokenCache.push(estimateTokens(messages[idx]));
430
+ }
431
+ let messagesTokens = 0;
432
+ for (const t of this.#messageTokenCache) messagesTokens += t;
433
+ if (messages.length > 0) {
434
+ messagesTokens += estimateTokens(messages[messages.length - 1]);
321
435
  }
436
+
437
+ const usedTokens = this.#nonMessageTokensCache + messagesTokens;
438
+ this.#cachedBreakdown = { usedTokens, contextWindow };
322
439
  return this.#cachedBreakdown;
323
440
  }
324
441
 
442
+ /**
443
+ * Build an identity fingerprint for the non-message inputs (system prompt,
444
+ * tools, skills). When this changes, the non-message token cache must be
445
+ * recomputed. Cheap: just lengths + first-string-length. Doesn't need to
446
+ * be cryptographically unique — only stable for the same inputs.
447
+ */
448
+ #computeNonMessageInputsKey(): string {
449
+ const sp = this.session.systemPrompt ?? [];
450
+ const tools = this.session.agent?.state?.tools ?? [];
451
+ const skills = this.session.skills ?? [];
452
+ const modelId = this.session.model?.id ?? "";
453
+ return `${modelId}|${sp.length}:${sp[0]?.length ?? 0}|${tools.length}|${skills.length}`;
454
+ }
455
+
325
456
  #buildSegmentContext(width: number): SegmentContext {
326
457
  const state = this.session.state;
327
458
 
459
+ // Trigger background fetch (5-min TTL); render uses cached value
460
+ this.#refreshUsageInBackground();
461
+
328
462
  // Get usage statistics
329
463
  const aggregateUsageStats = this.session.sessionManager?.getUsageStatistics() ?? {
330
464
  input: 0,
@@ -340,7 +474,7 @@ export class StatusLineComponent implements Component {
340
474
  };
341
475
 
342
476
  // Context usage — aligned with /context command so both surfaces report the same value
343
- const breakdown = this.#getCachedContextBreakdown();
477
+ const breakdown = this.getCachedContextBreakdown();
344
478
  const contextTokens = breakdown.usedTokens;
345
479
  const contextWindow = breakdown.contextWindow || state.model?.contextWindow || 0;
346
480
  const contextPercent = contextWindow > 0 ? (contextTokens / contextWindow) * 100 : 0;
@@ -363,6 +497,7 @@ export class StatusLineComponent implements Component {
363
497
  status: this.#getGitStatus(),
364
498
  pr: this.#lookupPr(),
365
499
  },
500
+ usage: this.#cachedUsage,
366
501
  };
367
502
  }
368
503
 
@@ -395,6 +395,15 @@ export class CommandController {
395
395
  info += `${theme.fg("dim", "Tool Calls:")} ${stats.toolCalls}\n`;
396
396
  info += `${theme.fg("dim", "Tool Results:")} ${stats.toolResults}\n`;
397
397
  info += `${theme.fg("dim", "Total:")} ${stats.totalMessages}\n\n`;
398
+ // Append-only context
399
+ {
400
+ const setting = this.ctx.settings.get("provider.appendOnlyContext") ?? "auto";
401
+ const provider = this.ctx.session.model?.provider;
402
+ const mode = setting === "on" ? true : setting === "off" ? false : provider === "deepseek";
403
+ const activeLabel = mode ? theme.fg("success", "active") : theme.fg("dim", "inactive");
404
+ const settingLabel = setting === "auto" ? `${setting} (${provider ?? "?"})` : setting;
405
+ info += `${theme.fg("dim", "Append-Only:")} ${activeLabel} (setting: ${settingLabel})\n`;
406
+ }
398
407
  info += `${theme.bold("Tokens")}\n`;
399
408
  info += `${theme.fg("dim", "Input:")} ${stats.tokens.input.toLocaleString()}\n`;
400
409
  info += `${theme.fg("dim", "Output:")} ${stats.tokens.output.toLocaleString()}\n`;
@@ -760,6 +760,14 @@ export class EventController {
760
760
  if (this.ctx.isBackgrounded === false) return;
761
761
  const notify = settings.get("completion.notify");
762
762
  if (notify === "off") return;
763
+
764
+ // Skip when the turn was aborted (e.g. ask cancelled with Ctrl+C) or
765
+ // errored — those are not "Task complete" events. Mirrors the gate
766
+ // already used by #currentContextTokens, #handleMessageEnd, and the
767
+ // retry / TTSR / compaction skip paths across agent-session.ts.
768
+ const last = this.ctx.session.getLastAssistantMessage?.();
769
+ if (last?.stopReason === "aborted" || last?.stopReason === "error") return;
770
+
763
771
  const title = this.ctx.sessionManager.getSessionName();
764
772
  const message = title ? `${title}: Complete` : "Complete";
765
773
  TERMINAL.sendNotification(message);
@@ -691,7 +691,7 @@ export class InteractiveMode implements InteractiveModeContext {
691
691
  }
692
692
 
693
693
  #isLoopAutoSubmitBlocked(): boolean {
694
- return this.session.isStreaming || this.session.isCompacting;
694
+ return this.session.isStreaming || this.session.isCompacting || this.session.hasPostPromptWork;
695
695
  }
696
696
 
697
697
  #submitLoopPromptWhenReady(prompt: string): void {
@@ -1876,12 +1876,23 @@ export class InteractiveMode implements InteractiveModeContext {
1876
1876
  }
1877
1877
  }
1878
1878
 
1879
- async #handleGoalSetSubcommand(rest: string): Promise<void> {
1880
- if (this.goalModeEnabled) {
1881
- this.showStatus("Goal mode is already active. Use /goal drop to start over.");
1882
- return;
1879
+ async #replaceGoalFromObjective(objective: string): Promise<void> {
1880
+ const state = await this.session.goalRuntime.replaceGoal({ objective });
1881
+ this.session.setGoalModeState(state);
1882
+ this.goalModeEnabled = true;
1883
+ this.goalModePaused = false;
1884
+ this.#resetGoalContinuationSuppression();
1885
+ this.#updateGoalModeStatus();
1886
+ if (this.session.isStreaming) {
1887
+ await this.session.sendGoalModeContext({ deliverAs: "steer" });
1883
1888
  }
1884
- if (this.#getPausedGoalState()) {
1889
+ if (this.onInputCallback) {
1890
+ this.onInputCallback(this.startPendingSubmission({ text: objective }));
1891
+ }
1892
+ }
1893
+
1894
+ async #handleGoalSetSubcommand(rest: string): Promise<void> {
1895
+ if (!this.goalModeEnabled && this.#getPausedGoalState()) {
1885
1896
  this.showWarning("Resume the current goal first, or drop it before setting a new objective.");
1886
1897
  return;
1887
1898
  }
@@ -1889,6 +1900,10 @@ export class InteractiveMode implements InteractiveModeContext {
1889
1900
  ? rest.trim()
1890
1901
  : (await this.showHookEditor("Goal objective", undefined, undefined, { promptStyle: true }))?.trim();
1891
1902
  if (!objective) return;
1903
+ if (this.goalModeEnabled) {
1904
+ await this.#replaceGoalFromObjective(objective);
1905
+ return;
1906
+ }
1892
1907
  await this.#startGoalFromObjective(objective);
1893
1908
  }
1894
1909
 
@@ -2312,8 +2327,8 @@ export class InteractiveMode implements InteractiveModeContext {
2312
2327
  this.#uiHelpers.renderSessionContext(sessionContext, options);
2313
2328
  }
2314
2329
 
2315
- renderInitialMessages(prebuiltContext?: SessionContext): void {
2316
- this.#uiHelpers.renderInitialMessages(prebuiltContext);
2330
+ renderInitialMessages(prebuiltContext?: SessionContext, options?: { preserveExistingChat?: boolean }): void {
2331
+ this.#uiHelpers.renderInitialMessages(prebuiltContext, options);
2317
2332
  }
2318
2333
 
2319
2334
  getUserMessageText(message: Message): string {
@@ -295,7 +295,7 @@ const UNICODE_SYMBOLS: SymbolMap = {
295
295
  "thinking.low": "◑ low",
296
296
  "thinking.medium": "◒ med",
297
297
  "thinking.high": "◕ high",
298
- "thinking.xhigh": "◉ xhi",
298
+ "thinking.xhigh": "◉ xhigh",
299
299
  // Checkboxes
300
300
  "checkbox.checked": "☑",
301
301
  "checkbox.unchecked": "☐",
@@ -186,7 +186,7 @@ export interface InteractiveModeContext {
186
186
  sessionContext: SessionContext,
187
187
  options?: { updateFooter?: boolean; populateHistory?: boolean },
188
188
  ): void;
189
- renderInitialMessages(prebuiltContext?: SessionContext): void;
189
+ renderInitialMessages(prebuiltContext?: SessionContext, options?: { preserveExistingChat?: boolean }): void;
190
190
  getUserMessageText(message: Message): string;
191
191
  findLastAssistantMessage(): AssistantMessage | undefined;
192
192
  extractAssistantText(message: AssistantMessage): string;
@@ -37,7 +37,7 @@ export interface ContextBreakdown {
37
37
  freeTokens: number;
38
38
  }
39
39
 
40
- function estimateSkillsTokens(skills: readonly Skill[]): number {
40
+ export function estimateSkillsTokens(skills: readonly Skill[]): number {
41
41
  const fragments: string[] = [];
42
42
  for (const skill of skills) {
43
43
  // "- name: description\n" wire framing tokenizes ~identically to the
@@ -47,7 +47,9 @@ function estimateSkillsTokens(skills: readonly Skill[]): number {
47
47
  return countTokens(fragments);
48
48
  }
49
49
 
50
- function estimateToolSchemaTokens(tools: ReadonlyArray<Pick<Tool, "name" | "description" | "parameters">>): number {
50
+ export function estimateToolSchemaTokens(
51
+ tools: ReadonlyArray<Pick<Tool, "name" | "description" | "parameters">>,
52
+ ): number {
51
53
  const fragments: string[] = [];
52
54
  for (const tool of tools) {
53
55
  fragments.push(tool.name, tool.description);
@@ -60,6 +62,27 @@ function estimateToolSchemaTokens(tools: ReadonlyArray<Pick<Tool, "name" | "desc
60
62
  return countTokens(fragments);
61
63
  }
62
64
 
65
+ /**
66
+ * Compute just the NON-MESSAGE token total: system prompt (with its skills
67
+ * section subtracted, since skills are tokenized separately) + system context
68
+ * (the rest of the system-prompt array) + tools + skills.
69
+ *
70
+ * Exposed so callers like `StatusLineComponent` can cache the non-message
71
+ * total separately from the message total. Non-message inputs (skills,
72
+ * tools, system prompt) change rarely; the message list grows on every
73
+ * streaming turn. Splitting the two lets the caller refresh each on its own
74
+ * cadence — non-message recomputed only when the inputs identity changes,
75
+ * messages walked incrementally as new entries append.
76
+ */
77
+ export function computeNonMessageTokens(session: AgentSession): number {
78
+ const skillsTokens = estimateSkillsTokens(session.skills ?? []);
79
+ const toolsTokens = estimateToolSchemaTokens(session.agent?.state?.tools ?? []);
80
+ const systemPromptParts = session.systemPrompt ?? [];
81
+ const systemContextTokens = countTokens(systemPromptParts.slice(1));
82
+ const systemPromptTokens = Math.max(0, countTokens(systemPromptParts[0] ?? "") - skillsTokens);
83
+ return systemPromptTokens + systemContextTokens + toolsTokens + skillsTokens;
84
+ }
85
+
63
86
  /**
64
87
  * Compute a breakdown of estimated context usage by category for the active
65
88
  * session and model.
@@ -29,6 +29,9 @@ import type { SessionContext } from "../../session/session-manager";
29
29
  import { formatBytes, formatDuration } from "../../tools/render-utils";
30
30
 
31
31
  type TextBlock = { type: "text"; text: string };
32
+ interface RenderInitialMessagesOptions {
33
+ preserveExistingChat?: boolean;
34
+ }
32
35
 
33
36
  type QueuedMessages = {
34
37
  steering: string[];
@@ -459,9 +462,10 @@ export class UiHelpers {
459
462
  this.ctx.ui.requestRender();
460
463
  }
461
464
 
462
- renderInitialMessages(prebuiltContext?: SessionContext): void {
465
+ renderInitialMessages(prebuiltContext?: SessionContext, options: RenderInitialMessagesOptions = {}): void {
463
466
  // This path is used to rebuild the visible chat transcript (e.g. after custom/debug UI).
464
467
  // Clear existing rendered chat first to avoid duplicating the full session in the container.
468
+ const preservedChatChildren = options.preserveExistingChat ? this.ctx.chatContainer.children : undefined;
465
469
  this.ctx.chatContainer.clear();
466
470
  this.ctx.pendingMessagesContainer.clear();
467
471
  this.ctx.pendingBashComponents = [];
@@ -486,6 +490,12 @@ export class UiHelpers {
486
490
  const times = compactionCount === 1 ? "1 time" : `${compactionCount} times`;
487
491
  this.ctx.showStatus(`Session compacted ${times}`);
488
492
  }
493
+ if (preservedChatChildren && preservedChatChildren.length > 0) {
494
+ for (const child of preservedChatChildren) {
495
+ this.ctx.chatContainer.addChild(child);
496
+ }
497
+ this.ctx.ui.requestRender();
498
+ }
489
499
  }
490
500
 
491
501
  clearEditor(): void {
@@ -6,5 +6,6 @@ description: {{jsonStringify description}}
6
6
  {{/if}}{{#if model}}model: {{jsonStringify model}}
7
7
  {{/if}}{{#if thinkingLevel}}thinking-level: {{jsonStringify thinkingLevel}}
8
8
  {{/if}}{{#if blocking}}blocking: true
9
+ {{/if}}{{#if autoloadSkills}}autoloadSkills: {{jsonStringify autoloadSkills}}
9
10
  {{/if}}---
10
11
  {{body}}