@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.
Files changed (72) hide show
  1. package/CHANGELOG.md +41 -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/hashline/constants.d.ts +0 -2
  7. package/dist/types/hashline/hash.d.ts +13 -39
  8. package/dist/types/hashline/parser.d.ts +2 -6
  9. package/dist/types/modes/components/status-line/types.d.ts +10 -0
  10. package/dist/types/modes/components/status-line.d.ts +10 -0
  11. package/dist/types/modes/interactive-mode.d.ts +3 -1
  12. package/dist/types/modes/shared.d.ts +9 -0
  13. package/dist/types/modes/theme/shimmer.d.ts +6 -3
  14. package/dist/types/modes/types.d.ts +3 -1
  15. package/dist/types/modes/utils/context-usage.d.ts +17 -0
  16. package/dist/types/modes/utils/ui-helpers.d.ts +5 -1
  17. package/dist/types/session/agent-session.d.ts +9 -0
  18. package/dist/types/task/executor.d.ts +3 -1
  19. package/dist/types/task/types.d.ts +35 -0
  20. package/dist/types/tools/bash-command-fixup.d.ts +0 -5
  21. package/dist/types/utils/clipboard.d.ts +3 -1
  22. package/dist/types/utils/image-resize.d.ts +4 -1
  23. package/package.json +7 -7
  24. package/src/config/prompt-templates.ts +1 -8
  25. package/src/config/settings-schema.ts +29 -1
  26. package/src/config/settings.ts +19 -0
  27. package/src/discovery/helpers.ts +5 -1
  28. package/src/edit/index.ts +1 -1
  29. package/src/edit/renderer.ts +5 -7
  30. package/src/edit/streaming.ts +24 -12
  31. package/src/extensibility/plugins/legacy-pi-compat.ts +27 -5
  32. package/src/goals/runtime.ts +35 -13
  33. package/src/hashline/constants.ts +0 -3
  34. package/src/hashline/diff.ts +1 -1
  35. package/src/hashline/execute.ts +2 -2
  36. package/src/hashline/grammar.lark +7 -8
  37. package/src/hashline/hash.ts +21 -43
  38. package/src/hashline/input.ts +15 -13
  39. package/src/hashline/parser.ts +62 -161
  40. package/src/internal-urls/docs-index.generated.ts +2 -2
  41. package/src/main.ts +1 -1
  42. package/src/modes/components/model-selector.ts +53 -22
  43. package/src/modes/components/status-line/segments.ts +53 -0
  44. package/src/modes/components/status-line/types.ts +4 -0
  45. package/src/modes/components/status-line.ts +147 -12
  46. package/src/modes/controllers/command-controller.ts +9 -0
  47. package/src/modes/controllers/event-controller.ts +10 -1
  48. package/src/modes/interactive-mode.ts +74 -18
  49. package/src/modes/shared.ts +16 -0
  50. package/src/modes/theme/shimmer.ts +15 -6
  51. package/src/modes/theme/theme.ts +1 -1
  52. package/src/modes/types.ts +1 -1
  53. package/src/modes/utils/context-usage.ts +25 -2
  54. package/src/modes/utils/ui-helpers.ts +11 -1
  55. package/src/prompts/agents/frontmatter.md +1 -0
  56. package/src/prompts/tools/hashline.md +62 -81
  57. package/src/sdk.ts +24 -0
  58. package/src/session/agent-session.ts +58 -0
  59. package/src/session/session-manager.ts +54 -1
  60. package/src/slash-commands/builtin-registry.ts +10 -0
  61. package/src/task/executor.ts +50 -1
  62. package/src/task/index.ts +11 -0
  63. package/src/task/render.ts +26 -2
  64. package/src/task/types.ts +35 -0
  65. package/src/tools/bash-command-fixup.ts +0 -10
  66. package/src/tools/bash.ts +1 -9
  67. package/src/utils/clipboard.ts +68 -3
  68. package/src/utils/commit-message-generator.ts +6 -1
  69. package/src/utils/image-resize.ts +51 -26
  70. package/src/utils/title-generator.ts +45 -13
  71. package/dist/types/modes/components/status-line-segment-editor.d.ts +0 -24
  72. 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 { 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`;
@@ -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} (esc to interrupt)`);
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
- function renderWorkingMessage(message: string): string {
123
- if (!message.endsWith(WORKING_INTERRUPT_HINT)) return shimmerText(message, theme);
124
- const header = message.slice(0, -WORKING_INTERRUPT_HINT.length);
125
- return shimmerSegments([{ text: header }, { text: WORKING_INTERRUPT_HINT, palette: HINT_SHIMMER_PALETTE }], theme);
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
- readonly #defaultWorkingMessage = `Working… (esc to interrupt)`;
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 #handleGoalSetSubcommand(rest: string): Promise<void> {
1852
- if (this.goalModeEnabled) {
1853
- this.showStatus("Goal mode is already active. Use /goal drop to start over.");
1854
- 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" });
1855
1888
  }
1856
- 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()) {
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 => theme.fg("accent", spinner),
2198
- renderWorkingMessage,
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 {
@@ -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: ThemeColor;
36
+ low: ShimmerPaletteTier;
31
37
  /** Color for chars approaching the crest (~0.22 ≤ intensity < ~0.65). */
32
- mid: ThemeColor;
38
+ mid: ShimmerPaletteTier;
33
39
  /** Color at the band's crest (intensity ≥ ~0.65). */
34
- high: ThemeColor;
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 highOpen = palette.bold ? `${BOLD_OPEN}${theme.getFgAnsi(palette.high)}` : theme.getFgAnsi(palette.high);
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: theme.getFgAnsi(palette.low), close: FG_RESET },
85
- mid: { open: theme.getFgAnsi(palette.mid), close: FG_RESET },
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;
@@ -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 {