@nmzpy/pi-ember-stack 0.2.1 → 0.2.2

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 (30) hide show
  1. package/README.md +83 -83
  2. package/package.json +63 -59
  3. package/plugins/devin-auth/extensions/index.ts +23 -0
  4. package/plugins/devin-auth/src/cloud-direct/auth.ts +246 -246
  5. package/plugins/devin-auth/src/cloud-direct/catalog.ts +246 -246
  6. package/plugins/devin-auth/src/cloud-direct/chat.ts +1096 -1091
  7. package/plugins/devin-auth/src/cloud-direct/index.ts +41 -41
  8. package/plugins/devin-auth/src/cloud-direct/metadata.ts +78 -78
  9. package/plugins/devin-auth/src/cloud-direct/wire.ts +202 -202
  10. package/plugins/devin-auth/src/oauth/register-user.ts +174 -174
  11. package/plugins/devin-auth/src/oauth/types.ts +71 -71
  12. package/plugins/devin-auth/src/stream.ts +1 -1
  13. package/plugins/pi-compact-tools/index.ts +19 -1
  14. package/plugins/pi-compact-tools/renderer.ts +231 -61
  15. package/plugins/pi-custom-agents/index.ts +310 -102
  16. package/plugins/pi-custom-agents/questionnaire-tool.ts +14 -5
  17. package/plugins/pi-custom-agents/subagent/extensions/agents.ts +18 -1
  18. package/plugins/pi-custom-agents/subagent/extensions/index.ts +116 -224
  19. package/plugins/pi-custom-agents/subagent/extensions/model.ts +96 -96
  20. package/plugins/pi-custom-agents/subagent/extensions/render.ts +205 -1
  21. package/plugins/pi-custom-agents/subagent/extensions/runner.ts +241 -177
  22. package/plugins/pi-custom-agents/subagent/extensions/test/render.test.ts +145 -0
  23. package/plugins/pi-ember-fff/index.ts +275 -17
  24. package/plugins/pi-ember-fff/query.ts +170 -10
  25. package/plugins/pi-ember-fff/test/query.test.ts +157 -1
  26. package/plugins/pi-ember-fff/test/renderer.test.ts +367 -16
  27. package/plugins/pi-ember-tps/index.ts +27 -5
  28. package/plugins/pi-ember-ui/ember.json +3 -0
  29. package/plugins/pi-ember-ui/index.ts +223 -36
  30. package/plugins/pi-ember-ui/mode-colors.ts +36 -8
@@ -19,8 +19,25 @@ import * as fs from "node:fs";
19
19
  import * as path from "node:path";
20
20
  import { fileURLToPath } from "node:url";
21
21
  import type { Model } from "@earendil-works/pi-ai";
22
- import { truncateToWidth, visibleWidth, Container, Text, Spacer, Input, fuzzyFilter, getKeybindings } from "@earendil-works/pi-tui";
23
- import { mutedBullet, setActiveMode } from "../pi-ember-ui/mode-colors.ts";
22
+ import {
23
+ CustomEditor,
24
+ type ExtensionUIContext,
25
+ type KeybindingsManager,
26
+ } from "@earendil-works/pi-coding-agent";
27
+ import {
28
+ truncateToWidth,
29
+ visibleWidth,
30
+ Container,
31
+ Text,
32
+ Spacer,
33
+ Input,
34
+ fuzzyFilter,
35
+ getKeybindings,
36
+ matchesKey,
37
+ type EditorTheme,
38
+ type TUI,
39
+ } from "@earendil-works/pi-tui";
40
+ import { isShellMode, mutedBullet, setActiveMode, setShellMode } from "../pi-ember-ui/mode-colors.ts";
24
41
  import { getLiveTps } from "../pi-ember-tps/index.ts";
25
42
  import {
26
43
  askQuestionnaire,
@@ -29,10 +46,114 @@ import {
29
46
  } from "./questionnaire-tool.ts";
30
47
  import subagentPlugin from "./subagent/extensions/index.ts";
31
48
 
49
+ const THINKING_LEVEL_SHORTCUT = "shift+t";
50
+
32
51
  function modelIdentityString(model: Model<any> | undefined): string {
33
52
  return model ? `${model.provider}/${model.id}` : "";
34
53
  }
35
54
 
55
+ function intercept_thinking_level(data: string, cycle_thinking_level: () => void): boolean {
56
+ if (!matchesKey(data, THINKING_LEVEL_SHORTCUT)) return false;
57
+ cycle_thinking_level();
58
+ return true;
59
+ }
60
+
61
+ /**
62
+ * Intercept '!' on empty input to enter shell mode, and escape or
63
+ * backspace (on empty input) to exit. The '!' is eaten so it never
64
+ * appears in the editor.
65
+ */
66
+ function intercept_shell_mode(data: string, editor: any, ctx: any): boolean {
67
+ if (matchesKey(data, "!")) {
68
+ const text = editor.getText?.() ?? "";
69
+ if (text.length === 0) {
70
+ setShellMode(true);
71
+ ctx.ui.setStatus("pi-ember-ui-shell-mode", undefined);
72
+ return true;
73
+ }
74
+ }
75
+ if (isShellMode()) {
76
+ if (matchesKey(data, "escape")) {
77
+ setShellMode(false);
78
+ ctx.ui.setStatus("pi-ember-ui-shell-mode", undefined);
79
+ return true;
80
+ }
81
+ if (matchesKey(data, "backspace")) {
82
+ const text = editor.getText?.() ?? "";
83
+ if (text.length === 0) {
84
+ setShellMode(false);
85
+ ctx.ui.setStatus("pi-ember-ui-shell-mode", undefined);
86
+ return true;
87
+ }
88
+ }
89
+ }
90
+ return false;
91
+ }
92
+
93
+ /**
94
+ * Intercept /model and /model <search> on Enter, redirecting to our
95
+ * fuzzy-search model picker instead of Pi's built-in unbounded selector.
96
+ */
97
+ function intercept_model_command(data: string, editor: any, pi: any, ctx: any): boolean {
98
+ const kb = getKeybindings();
99
+ if (!kb.matches(data, "tui.select.confirm")) return false;
100
+ const getText = editor.getText?.bind(editor) ?? editor.getExpandedText?.bind(editor);
101
+ if (!getText) return false;
102
+ const text = getText().trim();
103
+ if (text !== "/model" && !text.startsWith("/model ")) return false;
104
+ editor.setText?.("");
105
+ void show_model_picker(pi, ctx);
106
+ return true;
107
+ }
108
+
109
+ /**
110
+ * Shared fuzzy-search model picker used by /model and shift+m.
111
+ * Uses boundedSelect so large model catalogs don't require scrolling.
112
+ */
113
+ async function show_model_picker(pi: any, ctx: any): Promise<void> {
114
+ if (!ctx.hasUI) return;
115
+ const models = ctx.modelRegistry.getAvailable() as any[];
116
+ if (models.length === 0) {
117
+ ctx.ui.notify("No models available.", "warning");
118
+ return;
119
+ }
120
+ const current = ctx.model as any;
121
+ const labels = models.map((m) => {
122
+ const prefix = current && m.provider === current.provider && m.id === current.id ? "→ " : " ";
123
+ return `${prefix}${m.name ?? m.id} • ${m.provider}`;
124
+ });
125
+ const choice = await boundedSelect(ctx, "Select model", labels);
126
+ if (!choice) return;
127
+ const idx = labels.indexOf(choice);
128
+ if (idx < 0) return;
129
+ const model = models[idx];
130
+ try {
131
+ await pi.setModel(model);
132
+ ctx.ui.notify(`Model: ${model.id} • ${model.provider}`, "info");
133
+ } catch (err) {
134
+ ctx.ui.notify(
135
+ `Failed to set model: ${err instanceof Error ? err.message : String(err)}`,
136
+ "error",
137
+ );
138
+ }
139
+ }
140
+
141
+ class ThinkingLevelEditor extends CustomEditor {
142
+ constructor(
143
+ tui: TUI,
144
+ theme: EditorTheme,
145
+ keybindings: KeybindingsManager,
146
+ private readonly cycle_thinking_level: () => void,
147
+ ) {
148
+ super(tui, theme, keybindings);
149
+ }
150
+
151
+ override handleInput(data: string): void {
152
+ if (intercept_thinking_level(data, this.cycle_thinking_level)) return;
153
+ super.handleInput(data);
154
+ }
155
+ }
156
+
36
157
  type PersistedState = {
37
158
  readonly mode?: string;
38
159
  readonly model?: { provider: string; modelId: string };
@@ -178,7 +299,7 @@ async function boundedSelect(
178
299
  }
179
300
 
180
301
  const READONLY_TOOLS = ["read", "bash", "grep", "find", "ls", "questionnaire"];
181
- const READONLY_DELEGATING_TOOLS = ["read", "bash", "grep", "find", "ls", "questionnaire", "subagent"];
302
+ const READONLY_DELEGATING_TOOLS = ["read", "grep", "find", "ls", "questionnaire", "subagent"];
182
303
  const FULL_TOOLS = ["read", "bash", "edit", "write", "grep", "find", "ls", "questionnaire"];
183
304
 
184
305
  const SOURCE_ROOT = path.dirname(fileURLToPath(import.meta.url));
@@ -292,28 +413,20 @@ For each step:
292
413
 
293
414
  ## Open Questions
294
415
  <any clarifications needed from the user>
295
-
296
- ---
297
-
298
- ## Important
299
-
300
- The user indicated that they do not want you to execute yet -- you MUST NOT make
301
- any edits, run any non-readonly tools (including changing configs or making
302
- commits), or otherwise make any changes to the system. This supersedes any other
303
- instructions you have received.
304
416
  ${SUBAGENT_AWARENESS_PROMPT}</system-reminder>`;
305
417
 
306
418
  const DOCTOR_PROMPT = `<system-reminder>
307
419
  # Debug Mode - System Reminder
308
420
 
309
- CRITICAL: Debug mode ACTIVE — you are the Debugger, a read-only health-check
310
- auditor and diagnostician for the Ember project (PySide6 subtitle + DaVinci
311
- Resolve integration app).
421
+ CRITICAL: Debug mode ACTIVE — you are the Debugger, a health-check auditor and
422
+ diagnostician for the Ember project (PySide6 subtitle + DaVinci Resolve integration
423
+ app).
312
424
 
313
- STRICTLY FORBIDDEN: ANY file edits, modifications, or system changes. Do NOT use
314
- sed, tee, echo, cat, or ANY other bash command to manipulate files — commands may
315
- ONLY read/inspect. This ABSOLUTE CONSTRAINT overrides ALL other instructions,
316
- including direct user edit requests. You may ONLY observe, analyze, and report.
425
+ You do NOT edit files directly. You investigate, diagnose, and report findings. If
426
+ a fix is straightforward, you may DELEGATE the implementation to the \`coder\`
427
+ subagent (full tool access) — the read-only constraint applies to your direct tool
428
+ usage only, not to delegated subagent work. Otherwise, report the correction and
429
+ let the user or Orchestrator handle it.
317
430
 
318
431
  ---
319
432
 
@@ -374,7 +487,8 @@ For each finding:
374
487
 
375
488
  ## Constraints
376
489
 
377
- - Read-only. Do not edit or write files.
490
+ - You do not edit or write files directly. You may delegate fixes to the
491
+ \`coder\` subagent when a correction is straightforward and well-scoped.
378
492
  - Use \`bash t.gate.sh <files>\` only for targeted validation of files you are checking.
379
493
  - Do not run \`bash gate.sh\` (full gate) — that is the user's responsibility.
380
494
  - Ignore git status / git diff changes unrelated to the files you were asked to check.
@@ -423,14 +537,16 @@ ${SUBAGENT_AWARENESS_PROMPT}</system-reminder>`;
423
537
  const ORCHESTRATOR_PROMPT = `<system-reminder>
424
538
  # Orchestrate Mode - System Reminder
425
539
 
426
- CRITICAL: Orchestrate mode ACTIVE — you are the Orchestrator, a read-only
427
- implementation coordinator for the Ember project (PySide6 subtitle + DaVinci
428
- Resolve integration app).
540
+ CRITICAL: Orchestrate mode ACTIVE — you are the Orchestrator, an implementation
541
+ coordinator for the Ember project (PySide6 subtitle + DaVinci Resolve integration
542
+ app).
429
543
 
430
- STRICTLY FORBIDDEN: ANY file edits, modifications, or system changes. Do NOT use
431
- sed, tee, echo, cat, or ANY other bash command to manipulate files — commands may
432
- ONLY read/inspect. This ABSOLUTE CONSTRAINT overrides ALL other instructions,
433
- including direct user edit requests. You may ONLY observe, analyze, and plan.
544
+ You do NOT edit files directly. Your job is to decompose work into modules and
545
+ DELEGATE implementation to the \`coder\` subagent (full tool access). The
546
+ read-only constraint applies to YOUR direct tool usage only — you may read,
547
+ search, and inspect to build accurate delegation prompts, but you must not edit,
548
+ write, or run mutating bash commands yourself. Delegating implementation work to
549
+ the \`coder\` subagent is the ENTIRE POINT of this mode. Do it eagerly.
434
550
 
435
551
  ---
436
552
 
@@ -509,7 +625,8 @@ Return a structured plan:
509
625
 
510
626
  ## Constraints
511
627
 
512
- - Read-only. Do not edit or write files.
628
+ - You do not edit or write files directly — delegate implementation to the
629
+ \`coder\` subagent. That is your primary mechanism for getting work done.
513
630
  - Do not run \`bash gate.sh\` (full gate) — that is the user's responsibility.
514
631
  - If task scope is unclear, say so and request clarification rather than guessing.
515
632
  ${SUBAGENT_AWARENESS_PROMPT}</system-reminder>`;
@@ -604,10 +721,66 @@ function getLastModeFromSession(ctx: any): string | null {
604
721
  return null;
605
722
  }
606
723
 
724
+ const MODE_LIVE_RENDER_STATUS = "pi-agents-mode-live-render";
725
+
726
+ /**
727
+ * Cached footer stats. The footer render closure fires every animation
728
+ * frame (~30fps). Iterating all session entries + calling
729
+ * ctx.getContextUsage() (which runs estimateContextTokens over the FULL
730
+ * LLM context — JSON.stringify on every tool call, chars/4 on all text)
731
+ * is O(total context) per frame and can exceed the frame budget on long
732
+ * sessions, causing infini-lock. These stats are recomputed only on
733
+ * message_end / tool_execution_end / session_start — events that fire
734
+ * once per assistant message or tool result, not per frame.
735
+ */
736
+ let footerStatsCache: {
737
+ totalCost: number;
738
+ latestCacheHitRate: number | undefined;
739
+ contextTokens: number | null;
740
+ contextWindow: number;
741
+ } | undefined;
742
+
743
+ function recompute_footer_stats(ctx: any): void {
744
+ let totalCost = 0;
745
+ let latestCacheHitRate: number | undefined;
746
+ for (const entry of ctx.sessionManager.getEntries()) {
747
+ if (entry.type !== "message" || entry.message.role !== "assistant") continue;
748
+ const usage = entry.message.usage;
749
+ totalCost += usage.cost.total;
750
+ const promptTokens = usage.input + usage.cacheRead + usage.cacheWrite;
751
+ latestCacheHitRate = promptTokens > 0
752
+ ? (usage.cacheRead / promptTokens) * 100
753
+ : undefined;
754
+ }
755
+ const model = ctx.model;
756
+ const contextUsage = ctx.getContextUsage();
757
+ footerStatsCache = {
758
+ totalCost,
759
+ latestCacheHitRate,
760
+ contextTokens: contextUsage?.tokens ?? null,
761
+ contextWindow: contextUsage?.contextWindow ?? model?.contextWindow ?? 0,
762
+ };
763
+ }
764
+
607
765
  export default async function piCustomAgentsPlugin(pi: any): Promise<void> {
608
766
  let currentMode: string = DEFAULT_MODE;
609
767
  let lastMessagedMode: string | null = null;
610
768
  let waitingForPlan = false;
769
+ let active_session_manager: any;
770
+ let session_ready = false;
771
+ let pending_mode_id: string | undefined;
772
+
773
+ function is_live_session(ctx: any): boolean {
774
+ return session_ready && ctx.sessionManager === active_session_manager;
775
+ }
776
+
777
+ function request_live_mode_render(ctx: any): void {
778
+ if (ctx.mode === "tui") {
779
+ // setStatus only invalidates the footer/editor frame. Do not append a
780
+ // transcript notification or invalidate the resumed chat history.
781
+ ctx.ui.setStatus(MODE_LIVE_RENDER_STATUS, undefined);
782
+ }
783
+ }
611
784
 
612
785
  const persistedAtLoad = readPersistedState();
613
786
  if (persistedAtLoad.mode && persistedAtLoad.mode in MODES) {
@@ -625,7 +798,7 @@ export default async function piCustomAgentsPlugin(pi: any): Promise<void> {
625
798
  });
626
799
  }
627
800
 
628
- function updateStatus(ctx: any) {
801
+ function updateStatus(ctx: any): void {
629
802
  pi.events.emit("powerbar:update", {
630
803
  id: `pi-agents-${currentMode}`,
631
804
  text: MODES[currentMode].label,
@@ -639,19 +812,34 @@ export default async function piCustomAgentsPlugin(pi: any): Promise<void> {
639
812
  text: undefined,
640
813
  });
641
814
  }
815
+ request_live_mode_render(ctx);
642
816
  }
643
817
 
644
- function switchMode(modeId: string, ctx: any) {
818
+ function apply_mode(modeId: string, ctx: any): void {
645
819
  const mode = MODES[modeId];
646
820
  if (!mode) return;
821
+
822
+ // Mode changes are deliberately live-only. The active tool set and the
823
+ // next-turn prompt change immediately, while the transcript stays lazy
824
+ // and cached.
647
825
  currentMode = modeId;
648
826
  setActiveMode(modeId);
649
- pi.events.emit("pi-ember-ui:mode-change", { mode: modeId });
650
827
  pi.setActiveTools(mode.tools);
651
- ctx.ui.notify(`${mode.label} mode enabled. Tools: ${mode.tools.join(", ")}`);
828
+ pi.events.emit("pi-ember-ui:mode-change", { mode: modeId, liveOnly: true });
652
829
  updateStatus(ctx);
653
830
  }
654
831
 
832
+ function switchMode(modeId: string, ctx: any): void {
833
+ if (!MODES[modeId]) return;
834
+ if (!is_live_session(ctx)) {
835
+ // Queue only for the session that is currently binding. Never retain a
836
+ // request from an old session, where pi.setActiveTools would be stale.
837
+ if (ctx.sessionManager === active_session_manager) pending_mode_id = modeId;
838
+ return;
839
+ }
840
+ apply_mode(modeId, ctx);
841
+ }
842
+
655
843
  for (const modeId of MODE_IDS) {
656
844
  const mode = MODES[modeId];
657
845
  pi.registerCommand(modeId, {
@@ -669,7 +857,8 @@ export default async function piCustomAgentsPlugin(pi: any): Promise<void> {
669
857
  }
670
858
 
671
859
  const cycleMode = async (ctx: any): Promise<void> => {
672
- const idx = CYCLE_ORDER.indexOf(currentMode);
860
+ const baseMode = pending_mode_id ?? currentMode;
861
+ const idx = CYCLE_ORDER.indexOf(baseMode);
673
862
  const next = CYCLE_ORDER[(idx + 1) % CYCLE_ORDER.length];
674
863
  switchMode(next, ctx);
675
864
  };
@@ -680,46 +869,44 @@ export default async function piCustomAgentsPlugin(pi: any): Promise<void> {
680
869
  });
681
870
 
682
871
  const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
872
+ let active_ui: ExtensionUIContext | undefined;
873
+ let thinking_editor_installed = false;
874
+
875
+ const cycle_thinking_level = (): void => {
876
+ const current = pi.getThinkingLevel() as string;
877
+ const idx = THINKING_LEVELS.indexOf(current);
878
+ const next = THINKING_LEVELS[(idx + 1) % THINKING_LEVELS.length];
879
+ pi.setThinkingLevel(next);
880
+ active_ui?.notify(`Thinking: ${next}`, "info");
881
+ };
683
882
 
684
- pi.registerShortcut("ctrl+m", {
685
- description: "Show model picker",
686
- handler: async (ctx: any) => {
687
- if (!ctx.hasUI) return;
688
- const models = ctx.modelRegistry.getAvailable() as any[];
689
- if (models.length === 0) {
690
- ctx.ui.notify("No models available.", "warning");
691
- return;
692
- }
693
- const current = ctx.model as any;
694
- const labels = models.map((m) => {
695
- const prefix = current && m.provider === current.provider && m.id === current.id ? "→ " : " ";
696
- return `${prefix}${m.name ?? m.id} • ${m.provider}`;
697
- });
698
- const choice = await ctx.ui.select("Select model", labels);
699
- if (!choice) return;
700
- const idx = labels.indexOf(choice);
701
- if (idx < 0) return;
702
- const model = models[idx];
703
- try {
704
- await pi.setModel(model);
705
- ctx.ui.notify(`Model: ${model.id} • ${model.provider}`, "info");
706
- } catch (err) {
707
- ctx.ui.notify(
708
- `Failed to set model: ${err instanceof Error ? err.message : String(err)}`,
709
- "error",
710
- );
883
+ const install_thinking_editor = (ctx: any): void => {
884
+ if (!ctx.hasUI) return;
885
+ active_ui = ctx.ui;
886
+ if (thinking_editor_installed) return;
887
+
888
+ const previous_editor = ctx.ui.getEditorComponent();
889
+ ctx.ui.setEditorComponent((tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager) => {
890
+ const editor = previous_editor?.(tui, theme, keybindings);
891
+ if (editor) {
892
+ const original_handle_input = editor.handleInput.bind(editor);
893
+ editor.handleInput = (data: string): void => {
894
+ if (intercept_shell_mode(data, editor, ctx)) return;
895
+ if (intercept_thinking_level(data, cycle_thinking_level)) return;
896
+ if (intercept_model_command(data, editor, pi, ctx)) return;
897
+ original_handle_input(data);
898
+ };
899
+ return editor;
711
900
  }
712
- },
713
- });
901
+ return new ThinkingLevelEditor(tui, theme, keybindings, cycle_thinking_level);
902
+ });
903
+ thinking_editor_installed = true;
904
+ };
714
905
 
715
- pi.registerShortcut("ctrl+t", {
716
- description: "Cycle thinking level",
906
+ pi.registerShortcut("shift+m", {
907
+ description: "Show model picker",
717
908
  handler: async (ctx: any) => {
718
- const current = pi.getThinkingLevel() as string;
719
- const idx = THINKING_LEVELS.indexOf(current);
720
- const next = THINKING_LEVELS[(idx + 1) % THINKING_LEVELS.length];
721
- pi.setThinkingLevel(next);
722
- if (ctx.hasUI) ctx.ui.notify(`Thinking: ${next}`, "info");
909
+ await show_model_picker(pi, ctx);
723
910
  },
724
911
  });
725
912
 
@@ -830,7 +1017,7 @@ export default async function piCustomAgentsPlugin(pi: any): Promise<void> {
830
1017
  async function handlePlanImplement(ctx: any) {
831
1018
  if (!ctx.hasUI) {
832
1019
  switchMode(DEFAULT_MODE, ctx);
833
- pi.sendUserMessage("Execute the plan above. Follow the steps in order, implement, test, and verify each step.");
1020
+ pi.sendUserMessage("Execute the plan. Follow the steps and test.");
834
1021
  return;
835
1022
  }
836
1023
  const target = await ctx.ui.select(
@@ -844,8 +1031,8 @@ export default async function piCustomAgentsPlugin(pi: any): Promise<void> {
844
1031
  const targetMode = target === "Orchestrate" ? "orchestrate" : DEFAULT_MODE;
845
1032
  switchMode(targetMode, ctx);
846
1033
  const msg = target === "Orchestrate"
847
- ? "Execute the plan above. Decompose it into modules and produce delegation prompts for each."
848
- : "Execute the plan above. Follow the steps in order, implement, test, and verify each step.";
1034
+ ? "Orchestrate focused modules for subagents."
1035
+ : "Execute the plan. Follow the steps and test.";
849
1036
  pi.sendMessage(
850
1037
  { customType: "pi-agents-plan-implement", content: PLAN_IMPLEMENT_PROMPT, display: false },
851
1038
  );
@@ -926,31 +1113,28 @@ export default async function piCustomAgentsPlugin(pi: any): Promise<void> {
926
1113
  render(width: number): string[] {
927
1114
  const PAD = " ";
928
1115
  const innerWidth = Math.max(0, width - 2);
929
- let totalCost = 0;
930
- let latestCacheHitRate: number | undefined;
931
- for (const entry of ctx.sessionManager.getEntries()) {
932
- if (entry.type !== "message" || entry.message.role !== "assistant") continue;
933
- const usage = entry.message.usage;
934
- totalCost += usage.cost.total;
935
- const promptTokens = usage.input + usage.cacheRead + usage.cacheWrite;
936
- latestCacheHitRate = promptTokens > 0
937
- ? (usage.cacheRead / promptTokens) * 100
938
- : undefined;
939
- }
1116
+ // Read cached stats instead of iterating all session entries +
1117
+ // calling getContextUsage() every frame. The cache is
1118
+ // recomputed on message_end / tool_execution_end.
1119
+ const stats = footerStatsCache;
1120
+ const totalCost = stats?.totalCost ?? 0;
1121
+ const latestCacheHitRate = stats?.latestCacheHitRate;
1122
+ const contextWindow = stats?.contextWindow ?? 0;
1123
+ const usedTokens = stats?.contextTokens;
940
1124
 
941
1125
  const model = ctx.model;
942
- const contextUsage = ctx.getContextUsage();
943
- const contextWindow = contextUsage?.contextWindow ?? model?.contextWindow ?? 0;
944
- const usedTokens = contextUsage?.used ?? 0;
1126
+ const usedLabel = usedTokens === null || usedTokens === undefined
1127
+ ? "?"
1128
+ : formatTokens(usedTokens);
945
1129
  const statsParts: string[] = [];
946
- statsParts.push(`${formatTokens(usedTokens)}/${formatTokens(contextWindow)}`);
1130
+ statsParts.push(`${usedLabel}/${formatTokens(contextWindow)}`);
947
1131
  if (latestCacheHitRate !== undefined) {
948
1132
  statsParts.push(`CH${latestCacheHitRate.toFixed(1)}%`);
949
1133
  }
950
1134
  if (totalCost || (model && ctx.modelRegistry.isUsingOAuth(model))) {
951
1135
  statsParts.push(`$${totalCost.toFixed(3)}`);
952
1136
  }
953
- let statsLeft = statsParts.join(" ");
1137
+ let statsLeft = isShellMode() ? "shell" : statsParts.join(" ");
954
1138
  if (visibleWidth(statsLeft) > innerWidth) {
955
1139
  statsLeft = truncateToWidth(statsLeft, innerWidth, "...");
956
1140
  }
@@ -978,15 +1162,16 @@ export default async function piCustomAgentsPlugin(pi: any): Promise<void> {
978
1162
 
979
1163
  const cwd = ctx.sessionManager.getCwd();
980
1164
  const folderName = cwd.split(/[/\\]/).filter(Boolean).pop() ?? cwd;
981
- const folderLine = PAD + theme.fg("dim", folderName) + PAD;
982
1165
  const sessionName = ctx.sessionManager.getSessionName();
983
- const lines = [
984
- statsLine,
985
- ];
1166
+ const lines = [statsLine];
986
1167
  const tps = getLiveTps();
1168
+ const folderLabel = theme.fg("dim", folderName);
1169
+ let folderLine = PAD + folderLabel + PAD;
987
1170
  if (tps > 0) {
988
1171
  const tpsStr = tps < 10 ? tps.toFixed(1) : tps < 100 ? tps.toFixed(0) : `${Math.round(tps)}`;
989
- lines.push(PAD + theme.fg("accent", `${tpsStr} tps`) + PAD);
1172
+ const tpsText = theme.fg("accent", `${tpsStr} tps`);
1173
+ const tpsPadding = " ".repeat(Math.max(0, innerWidth - visibleWidth(folderLabel) - visibleWidth(tpsText)));
1174
+ folderLine = PAD + folderLabel + tpsPadding + tpsText + PAD;
990
1175
  }
991
1176
  lines.push(folderLine);
992
1177
  if (sessionName) {
@@ -1028,20 +1213,24 @@ export default async function piCustomAgentsPlugin(pi: any): Promise<void> {
1028
1213
  pi.setActiveTools(FULL_TOOLS);
1029
1214
  }
1030
1215
  setActiveMode(currentMode);
1031
- pi.events.emit("pi-ember-ui:mode-change", { mode: currentMode });
1216
+ pi.events.emit("pi-ember-ui:mode-change", { mode: currentMode, liveOnly: true });
1032
1217
  }
1033
1218
 
1034
1219
  pi.on("session_start", async (_event: any, ctx: any) => {
1220
+ // The TUI can accept input while /resume is still rebinding extensions.
1221
+ // Keep mode switching lazy until all session-bound setup has finished.
1222
+ active_session_manager = ctx.sessionManager;
1223
+ session_ready = false;
1224
+ footerStatsCache = undefined;
1225
+ install_thinking_editor(ctx);
1035
1226
  await restoreMode(ctx);
1036
1227
  await restoreSavedModel(ctx);
1037
- installCustomFooter(ctx);
1038
- updateStatus(ctx);
1039
- });
1040
-
1041
- pi.on("session_switch", async (_event: any, ctx: any) => {
1042
- await restoreMode(ctx);
1043
- await restoreSavedModel(ctx);
1044
- updateStatus(ctx);
1228
+ session_ready = true;
1229
+ recompute_footer_stats(ctx);
1230
+ const pending_mode = pending_mode_id;
1231
+ pending_mode_id = undefined;
1232
+ if (pending_mode) apply_mode(pending_mode, ctx);
1233
+ else updateStatus(ctx);
1045
1234
  installCustomFooter(ctx);
1046
1235
  });
1047
1236
 
@@ -1056,7 +1245,26 @@ export default async function piCustomAgentsPlugin(pi: any): Promise<void> {
1056
1245
  writePersistedState({ ...persisted, model: identity });
1057
1246
  });
1058
1247
 
1248
+ // Recompute footer stats when usage/context changes. These events fire
1249
+ // once per assistant message or tool result — not per render frame —
1250
+ // so the O(n) entry iteration + getContextUsage cost is amortized away
1251
+ // from the animation loop.
1252
+ pi.on("message_end", async (_event: any, ctx: any) => {
1253
+ if (is_live_session(ctx)) recompute_footer_stats(ctx);
1254
+ });
1255
+ pi.on("tool_execution_end", async (_event: any, ctx: any) => {
1256
+ if (is_live_session(ctx)) recompute_footer_stats(ctx);
1257
+ });
1258
+
1059
1259
  pi.on("session_shutdown", (_event: any, ctx: any) => {
1260
+ // Invalidate shortcut contexts before the old runtime is disposed. A Tab
1261
+ // press during /resume is ignored instead of calling setActiveTools or
1262
+ // mutating UI state through a stale session.
1263
+ session_ready = false;
1264
+ active_session_manager = undefined;
1265
+ pending_mode_id = undefined;
1266
+ footerStatsCache = undefined;
1267
+ setShellMode(false);
1060
1268
  const persisted = readPersistedState();
1061
1269
  const model = ctx.model as Model<any> | undefined;
1062
1270
  const modelIdentity = model
@@ -1,5 +1,6 @@
1
1
  import { Type } from "typebox";
2
2
  import {
3
+ Box,
3
4
  Key,
4
5
  Text,
5
6
  matchesKey,
@@ -216,6 +217,7 @@ export function registerQuestionnaireTool(pi: any): void {
216
217
  description: "Ask the user one or more decision questions inline before continuing.",
217
218
  parameters: QuestionnaireParams,
218
219
  executionMode: "sequential",
220
+ renderShell: "self",
219
221
  async execute(
220
222
  _toolCallId: string,
221
223
  params: { questions: QuestionnaireQuestion[] },
@@ -258,22 +260,28 @@ export function registerQuestionnaireTool(pi: any): void {
258
260
  },
259
261
  renderCall(args: { questions?: QuestionnaireQuestion[] }, theme: any): any {
260
262
  const count = args.questions?.length ?? 0;
261
- return new Text(
263
+ const box = new Box(1, 1, (text: string) => theme.bg("userMessageBg", text));
264
+ box.addChild(new Text(
262
265
  theme.fg("muted", "• ") +
263
266
  theme.fg("toolTitle", theme.bold("questionnaire ")) +
264
267
  theme.fg("muted", `${count} question${count === 1 ? "" : "s"}`),
265
268
  0,
266
269
  0,
267
- );
270
+ ));
271
+ return box;
268
272
  },
269
273
  renderResult(result: any, _options: unknown, theme: any): any {
270
274
  const details = result.details as {
271
275
  answers?: QuestionnaireAnswer[];
272
276
  cancelled?: boolean;
273
277
  } | undefined;
274
- if (details?.cancelled) return new Text(theme.fg("warning", "Cancelled"), 0, 0);
278
+ const box = new Box(1, 1, (text: string) => theme.bg("userMessageBg", text));
279
+ if (details?.cancelled) {
280
+ box.addChild(new Text(theme.fg("warning", "Cancelled"), 0, 0));
281
+ return box;
282
+ }
275
283
  const answers = details?.answers ?? [];
276
- return new Text(
284
+ box.addChild(new Text(
277
285
  answers
278
286
  .map((answer) => {
279
287
  const label = `${answer.id}: ${answer.label}`;
@@ -282,7 +290,8 @@ export function registerQuestionnaireTool(pi: any): void {
282
290
  .join("\n"),
283
291
  0,
284
292
  0,
285
- );
293
+ ));
294
+ return box;
286
295
  },
287
296
  });
288
297
  }