@oh-my-pi/pi-coding-agent 15.2.3 → 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 +41 -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/hashline/constants.d.ts +0 -2
- package/dist/types/hashline/hash.d.ts +13 -39
- package/dist/types/hashline/parser.d.ts +2 -6
- 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/shared.d.ts +9 -0
- package/dist/types/modes/theme/shimmer.d.ts +6 -3
- 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/prompt-templates.ts +1 -8
- package/src/config/settings-schema.ts +29 -1
- package/src/config/settings.ts +19 -0
- package/src/discovery/helpers.ts +5 -1
- package/src/edit/index.ts +1 -1
- package/src/edit/renderer.ts +5 -7
- package/src/edit/streaming.ts +24 -12
- package/src/extensibility/plugins/legacy-pi-compat.ts +27 -5
- package/src/goals/runtime.ts +35 -13
- package/src/hashline/constants.ts +0 -3
- package/src/hashline/diff.ts +1 -1
- package/src/hashline/execute.ts +2 -2
- package/src/hashline/grammar.lark +7 -8
- package/src/hashline/hash.ts +21 -43
- package/src/hashline/input.ts +15 -13
- package/src/hashline/parser.ts +62 -161
- package/src/internal-urls/docs-index.generated.ts +2 -2
- 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 +10 -1
- package/src/modes/interactive-mode.ts +74 -18
- package/src/modes/shared.ts +16 -0
- package/src/modes/theme/shimmer.ts +15 -6
- 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/prompts/tools/hashline.md +62 -81
- 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/commit-message-generator.ts +6 -1
- package/src/utils/image-resize.ts +51 -26
- package/src/utils/title-generator.ts +45 -13
- package/dist/types/modes/components/status-line-segment-editor.d.ts +0 -24
- package/src/modes/components/status-line-segment-editor.ts +0 -359
|
@@ -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`;
|
|
@@ -18,6 +18,7 @@ import type { PlanApprovalDetails } from "../../plan-mode/approved-plan";
|
|
|
18
18
|
import type { AgentSessionEvent } from "../../session/agent-session";
|
|
19
19
|
import { isSilentAbort, readPendingDisplayTag } from "../../session/messages";
|
|
20
20
|
import type { ResolveToolDetails } from "../../tools/resolve";
|
|
21
|
+
import { interruptHint } from "../shared";
|
|
21
22
|
|
|
22
23
|
type AgentSessionEventKind = AgentSessionEvent["type"];
|
|
23
24
|
|
|
@@ -133,7 +134,7 @@ export class EventController {
|
|
|
133
134
|
const trimmed = intent.trim();
|
|
134
135
|
if (!trimmed || trimmed === this.#lastIntent) return;
|
|
135
136
|
this.#lastIntent = trimmed;
|
|
136
|
-
this.ctx.setWorkingMessage(`${trimmed}
|
|
137
|
+
this.ctx.setWorkingMessage(`${trimmed}${interruptHint()}`);
|
|
137
138
|
}
|
|
138
139
|
|
|
139
140
|
subscribeToAgent(): void {
|
|
@@ -759,6 +760,14 @@ export class EventController {
|
|
|
759
760
|
if (this.ctx.isBackgrounded === false) return;
|
|
760
761
|
const notify = settings.get("completion.notify");
|
|
761
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
|
+
|
|
762
771
|
const title = this.ctx.sessionManager.getSessionName();
|
|
763
772
|
const message = title ? `${title}: Complete` : "Complete";
|
|
764
773
|
TERMINAL.sendNotification(message);
|
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
TUI,
|
|
27
27
|
visibleWidth,
|
|
28
28
|
} from "@oh-my-pi/pi-tui";
|
|
29
|
-
import { APP_NAME, getProjectDir, hsvToRgb, isEnoent, logger, postmortem, prompt } from "@oh-my-pi/pi-utils";
|
|
29
|
+
import { APP_NAME, adjustHsv, getProjectDir, hsvToRgb, isEnoent, logger, postmortem, prompt } from "@oh-my-pi/pi-utils";
|
|
30
30
|
import chalk from "chalk";
|
|
31
31
|
import { KeybindingsManager } from "../config/keybindings";
|
|
32
32
|
import { isSettingsInitialized, Settings, settings } from "../config/settings";
|
|
@@ -98,6 +98,7 @@ import {
|
|
|
98
98
|
} from "./loop-limit";
|
|
99
99
|
import { OAuthManualInputManager } from "./oauth-manual-input";
|
|
100
100
|
import { SessionObserverRegistry } from "./session-observer-registry";
|
|
101
|
+
import { interruptHint } from "./shared";
|
|
101
102
|
import { type ShimmerPalette, shimmerSegments, shimmerText } from "./theme/shimmer";
|
|
102
103
|
import type { Theme } from "./theme/theme";
|
|
103
104
|
import {
|
|
@@ -111,18 +112,43 @@ import {
|
|
|
111
112
|
import type { CompactionQueuedMessage, InteractiveModeContext, SubmittedUserInput, TodoItem, TodoPhase } from "./types";
|
|
112
113
|
import { UiHelpers } from "./utils/ui-helpers";
|
|
113
114
|
|
|
114
|
-
const WORKING_INTERRUPT_HINT = " (esc to interrupt)";
|
|
115
|
-
|
|
116
115
|
const HINT_SHIMMER_PALETTE: ShimmerPalette = {
|
|
117
116
|
low: "dim",
|
|
118
117
|
mid: "muted",
|
|
119
118
|
high: "borderAccent",
|
|
120
119
|
};
|
|
121
120
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
121
|
+
interface WorkingMessageAccent {
|
|
122
|
+
main: string;
|
|
123
|
+
dim: string;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function renderWorkingMessage(message: string, accent?: WorkingMessageAccent): string {
|
|
127
|
+
const palette = accent
|
|
128
|
+
? ({
|
|
129
|
+
low: "dim",
|
|
130
|
+
mid: { ansi: accent.main },
|
|
131
|
+
high: { ansi: accent.main },
|
|
132
|
+
bold: true,
|
|
133
|
+
} satisfies ShimmerPalette)
|
|
134
|
+
: undefined;
|
|
135
|
+
const hint = interruptHint();
|
|
136
|
+
if (!message.endsWith(hint)) return shimmerText(message, theme, palette);
|
|
137
|
+
const header = message.slice(0, -hint.length);
|
|
138
|
+
const hintPalette = accent
|
|
139
|
+
? ({
|
|
140
|
+
low: "dim",
|
|
141
|
+
mid: { ansi: accent.dim },
|
|
142
|
+
high: { ansi: accent.dim },
|
|
143
|
+
} satisfies ShimmerPalette)
|
|
144
|
+
: HINT_SHIMMER_PALETTE;
|
|
145
|
+
return shimmerSegments(
|
|
146
|
+
[
|
|
147
|
+
{ text: header, palette },
|
|
148
|
+
{ text: hint, palette: hintPalette },
|
|
149
|
+
],
|
|
150
|
+
theme,
|
|
151
|
+
);
|
|
126
152
|
}
|
|
127
153
|
|
|
128
154
|
const EDITOR_MAX_HEIGHT_MIN = 6;
|
|
@@ -232,7 +258,9 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
232
258
|
autoCompactionLoader: Loader | undefined = undefined;
|
|
233
259
|
retryLoader: Loader | undefined = undefined;
|
|
234
260
|
#pendingWorkingMessage: string | undefined;
|
|
235
|
-
|
|
261
|
+
get #defaultWorkingMessage(): string {
|
|
262
|
+
return `Working…${interruptHint()}`;
|
|
263
|
+
}
|
|
236
264
|
autoCompactionEscapeHandler?: () => void;
|
|
237
265
|
retryEscapeHandler?: () => void;
|
|
238
266
|
unsubscribe?: () => void;
|
|
@@ -663,7 +691,7 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
663
691
|
}
|
|
664
692
|
|
|
665
693
|
#isLoopAutoSubmitBlocked(): boolean {
|
|
666
|
-
return this.session.isStreaming || this.session.isCompacting;
|
|
694
|
+
return this.session.isStreaming || this.session.isCompacting || this.session.hasPostPromptWork;
|
|
667
695
|
}
|
|
668
696
|
|
|
669
697
|
#submitLoopPromptWhenReady(prompt: string): void {
|
|
@@ -1848,12 +1876,23 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
1848
1876
|
}
|
|
1849
1877
|
}
|
|
1850
1878
|
|
|
1851
|
-
async #
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
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" });
|
|
1855
1888
|
}
|
|
1856
|
-
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()) {
|
|
1857
1896
|
this.showWarning("Resume the current goal first, or drop it before setting a new objective.");
|
|
1858
1897
|
return;
|
|
1859
1898
|
}
|
|
@@ -1861,6 +1900,10 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
1861
1900
|
? rest.trim()
|
|
1862
1901
|
: (await this.showHookEditor("Goal objective", undefined, undefined, { promptStyle: true }))?.trim();
|
|
1863
1902
|
if (!objective) return;
|
|
1903
|
+
if (this.goalModeEnabled) {
|
|
1904
|
+
await this.#replaceGoalFromObjective(objective);
|
|
1905
|
+
return;
|
|
1906
|
+
}
|
|
1864
1907
|
await this.#startGoalFromObjective(objective);
|
|
1865
1908
|
}
|
|
1866
1909
|
|
|
@@ -2189,13 +2232,26 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
2189
2232
|
this.ui.requestRender();
|
|
2190
2233
|
}
|
|
2191
2234
|
|
|
2235
|
+
#getWorkingMessageAccent(): WorkingMessageAccent | undefined {
|
|
2236
|
+
const accentEnabled = !isSettingsInitialized() || settings.get("statusLine.sessionAccent") !== false;
|
|
2237
|
+
const sessionName = accentEnabled ? this.sessionManager.getSessionName() : undefined;
|
|
2238
|
+
if (!sessionName) return undefined;
|
|
2239
|
+
const hex = getSessionAccentHex(sessionName);
|
|
2240
|
+
const main = getSessionAccentAnsi(hex);
|
|
2241
|
+
const dim = getSessionAccentAnsi(adjustHsv(hex, { s: 0.55, v: 0.65 }));
|
|
2242
|
+
return main && dim ? { main, dim } : undefined;
|
|
2243
|
+
}
|
|
2244
|
+
|
|
2192
2245
|
ensureLoadingAnimation(): void {
|
|
2193
2246
|
if (!this.loadingAnimation) {
|
|
2194
2247
|
this.statusContainer.clear();
|
|
2195
2248
|
this.loadingAnimation = new Loader(
|
|
2196
2249
|
this.ui,
|
|
2197
|
-
spinner =>
|
|
2198
|
-
|
|
2250
|
+
spinner => {
|
|
2251
|
+
const accent = this.#getWorkingMessageAccent();
|
|
2252
|
+
return accent ? `${accent.main}${spinner}\x1b[39m` : theme.fg("accent", spinner);
|
|
2253
|
+
},
|
|
2254
|
+
message => renderWorkingMessage(message, this.#getWorkingMessageAccent()),
|
|
2199
2255
|
this.#defaultWorkingMessage,
|
|
2200
2256
|
getSymbolTheme().spinnerFrames,
|
|
2201
2257
|
);
|
|
@@ -2271,8 +2327,8 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
2271
2327
|
this.#uiHelpers.renderSessionContext(sessionContext, options);
|
|
2272
2328
|
}
|
|
2273
2329
|
|
|
2274
|
-
renderInitialMessages(prebuiltContext?: SessionContext): void {
|
|
2275
|
-
this.#uiHelpers.renderInitialMessages(prebuiltContext);
|
|
2330
|
+
renderInitialMessages(prebuiltContext?: SessionContext, options?: { preserveExistingChat?: boolean }): void {
|
|
2331
|
+
this.#uiHelpers.renderInitialMessages(prebuiltContext, options);
|
|
2276
2332
|
}
|
|
2277
2333
|
|
|
2278
2334
|
getUserMessageText(message: Message): string {
|
package/src/modes/shared.ts
CHANGED
|
@@ -36,4 +36,20 @@ export function getTabBarTheme(): TabBarTheme {
|
|
|
36
36
|
};
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
40
|
+
// Working-message hint
|
|
41
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Suffix appended to the loader's working message to remind users they can
|
|
45
|
+
* abort with Esc. Rendered with the active theme's bracket glyphs so it stays
|
|
46
|
+
* visually consistent with badges and other bracketed UI affordances.
|
|
47
|
+
*
|
|
48
|
+
* The leading space separates the hint from the message body and is consumed
|
|
49
|
+
* by `endsWith`/`slice` matching in the loader renderer.
|
|
50
|
+
*/
|
|
51
|
+
export function interruptHint(): string {
|
|
52
|
+
return ` ${theme.format.bracketLeft}esc${theme.format.bracketRight}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
39
55
|
export { parseCommandArgs } from "../utils/command-args";
|
|
@@ -24,14 +24,20 @@ const BOLD_CLOSE = "\x1b[22m";
|
|
|
24
24
|
type ShimmerTheme = Pick<Theme, "bold" | "fg" | "getFgAnsi">;
|
|
25
25
|
type ShimmerMode = "classic" | "kitt" | "disabled";
|
|
26
26
|
|
|
27
|
+
type ShimmerPaletteTier = ThemeColor | { ansi: string };
|
|
28
|
+
|
|
29
|
+
function resolveTierAnsi(theme: ShimmerTheme, tier: ShimmerPaletteTier): string {
|
|
30
|
+
return typeof tier === "string" ? theme.getFgAnsi(tier) : tier.ansi;
|
|
31
|
+
}
|
|
32
|
+
|
|
27
33
|
/** Three-tier color stack a shimmer character cycles through as the band sweeps. */
|
|
28
34
|
export interface ShimmerPalette {
|
|
29
35
|
/** Color for chars outside / at the edge of the band (intensity < ~0.22). */
|
|
30
|
-
low:
|
|
36
|
+
low: ShimmerPaletteTier;
|
|
31
37
|
/** Color for chars approaching the crest (~0.22 ≤ intensity < ~0.65). */
|
|
32
|
-
mid:
|
|
38
|
+
mid: ShimmerPaletteTier;
|
|
33
39
|
/** Color at the band's crest (intensity ≥ ~0.65). */
|
|
34
|
-
high:
|
|
40
|
+
high: ShimmerPaletteTier;
|
|
35
41
|
/** Whether to bold the crest tier. Default `false`. */
|
|
36
42
|
bold?: boolean;
|
|
37
43
|
}
|
|
@@ -78,11 +84,14 @@ function compile(theme: ShimmerTheme, palette: ShimmerPalette): CompiledPalette
|
|
|
78
84
|
const p = palette as ShimmerPalette & PaletteCache;
|
|
79
85
|
const cached = p[kCompiled];
|
|
80
86
|
if (cached && p[kCompiledFor] === theme) return cached;
|
|
81
|
-
const
|
|
87
|
+
const lowOpen = resolveTierAnsi(theme, palette.low);
|
|
88
|
+
const midOpen = resolveTierAnsi(theme, palette.mid);
|
|
89
|
+
const highColorOpen = resolveTierAnsi(theme, palette.high);
|
|
90
|
+
const highOpen = palette.bold ? `${BOLD_OPEN}${highColorOpen}` : highColorOpen;
|
|
82
91
|
const highClose = palette.bold ? `${BOLD_CLOSE}${FG_RESET}` : FG_RESET;
|
|
83
92
|
const out: CompiledPalette = {
|
|
84
|
-
low: { open:
|
|
85
|
-
mid: { open:
|
|
93
|
+
low: { open: lowOpen, close: FG_RESET },
|
|
94
|
+
mid: { open: midOpen, close: FG_RESET },
|
|
86
95
|
high: { open: highOpen, close: highClose },
|
|
87
96
|
};
|
|
88
97
|
p[kCompiledFor] = theme;
|
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 {
|