@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.
- package/CHANGELOG.md +20 -0
- package/dist/types/config/settings-schema.d.ts +34 -1
- package/dist/types/config/settings.d.ts +6 -0
- package/dist/types/discovery/helpers.d.ts +1 -0
- package/dist/types/goals/runtime.d.ts +4 -0
- package/dist/types/modes/components/status-line/types.d.ts +10 -0
- package/dist/types/modes/components/status-line.d.ts +10 -0
- package/dist/types/modes/interactive-mode.d.ts +3 -1
- package/dist/types/modes/types.d.ts +3 -1
- package/dist/types/modes/utils/context-usage.d.ts +17 -0
- package/dist/types/modes/utils/ui-helpers.d.ts +5 -1
- package/dist/types/session/agent-session.d.ts +9 -0
- package/dist/types/task/executor.d.ts +3 -1
- package/dist/types/task/types.d.ts +35 -0
- package/dist/types/tools/bash-command-fixup.d.ts +0 -5
- package/dist/types/utils/clipboard.d.ts +3 -1
- package/dist/types/utils/image-resize.d.ts +4 -1
- package/package.json +7 -7
- package/src/config/settings-schema.ts +29 -1
- package/src/config/settings.ts +19 -0
- package/src/discovery/helpers.ts +5 -1
- package/src/extensibility/plugins/legacy-pi-compat.ts +27 -5
- package/src/goals/runtime.ts +35 -13
- package/src/main.ts +1 -1
- package/src/modes/components/model-selector.ts +53 -22
- package/src/modes/components/status-line/segments.ts +53 -0
- package/src/modes/components/status-line/types.ts +4 -0
- package/src/modes/components/status-line.ts +147 -12
- package/src/modes/controllers/command-controller.ts +9 -0
- package/src/modes/controllers/event-controller.ts +8 -0
- package/src/modes/interactive-mode.ts +23 -8
- package/src/modes/theme/theme.ts +1 -1
- package/src/modes/types.ts +1 -1
- package/src/modes/utils/context-usage.ts +25 -2
- package/src/modes/utils/ui-helpers.ts +11 -1
- package/src/prompts/agents/frontmatter.md +1 -0
- package/src/sdk.ts +24 -0
- package/src/session/agent-session.ts +58 -0
- package/src/session/session-manager.ts +54 -1
- package/src/slash-commands/builtin-registry.ts +10 -0
- package/src/task/executor.ts +50 -1
- package/src/task/index.ts +11 -0
- package/src/task/render.ts +26 -2
- package/src/task/types.ts +35 -0
- package/src/tools/bash-command-fixup.ts +0 -10
- package/src/tools/bash.ts +1 -9
- package/src/utils/clipboard.ts +68 -3
- package/src/utils/image-resize.ts +51 -26
- package/dist/types/modes/components/status-line-segment-editor.d.ts +0 -24
- package/src/modes/components/status-line-segment-editor.ts +0 -359
package/src/main.ts
CHANGED
|
@@ -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:
|
|
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
|
|
455
|
+
providerSet.add(item.provider);
|
|
438
456
|
}
|
|
439
457
|
for (const provider of this.#modelRegistry.getDiscoverableProviders()) {
|
|
440
|
-
providerSet.add(provider
|
|
458
|
+
providerSet.add(provider);
|
|
441
459
|
}
|
|
442
|
-
const
|
|
443
|
-
|
|
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
|
|
448
|
-
if (this.#scopedModels.length > 0 ||
|
|
470
|
+
const providerId = this.#getActiveProviderId();
|
|
471
|
+
if (this.#scopedModels.length > 0 || !providerId) {
|
|
449
472
|
return;
|
|
450
473
|
}
|
|
451
|
-
await this.#modelRegistry.refreshProvider(
|
|
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
|
-
#
|
|
479
|
-
return this.#providers[this.#activeTabIndex] ??
|
|
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.#
|
|
514
|
+
return this.#getActiveTabId() === CANONICAL_TAB;
|
|
484
515
|
}
|
|
485
516
|
|
|
486
517
|
#filterModels(query: string): void {
|
|
487
|
-
const
|
|
488
|
-
const
|
|
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 (
|
|
494
|
-
baseModels = this.#allModels.filter(m => m.provider
|
|
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 (
|
|
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
|
|
581
|
-
if (
|
|
612
|
+
const activeProviderId = this.#getActiveProviderId();
|
|
613
|
+
if (!activeProviderId || this.#searchInput.getValue().trim()) {
|
|
582
614
|
return undefined;
|
|
583
615
|
}
|
|
584
|
-
const state = this.#modelRegistry.getProviderDiscoveryState(
|
|
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
|
|
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 {
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
#
|
|
334
|
+
#refreshUsageInBackground(): void {
|
|
313
335
|
const now = Date.now();
|
|
314
|
-
if (
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
this
|
|
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
|
|
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 #
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
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
|
|
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 {
|
package/src/modes/theme/theme.ts
CHANGED
|
@@ -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": "◉
|
|
298
|
+
"thinking.xhigh": "◉ xhigh",
|
|
299
299
|
// Checkboxes
|
|
300
300
|
"checkbox.checked": "☑",
|
|
301
301
|
"checkbox.unchecked": "☐",
|
package/src/modes/types.ts
CHANGED
|
@@ -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(
|
|
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}}
|