@oh-my-pi/pi-coding-agent 5.5.0 → 5.6.70

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 (98) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/docs/python-repl.md +77 -0
  3. package/examples/hooks/snake.ts +7 -7
  4. package/package.json +5 -5
  5. package/src/bun-imports.d.ts +6 -0
  6. package/src/cli/args.ts +7 -0
  7. package/src/cli/setup-cli.ts +231 -0
  8. package/src/cli.ts +2 -0
  9. package/src/core/agent-session.ts +118 -15
  10. package/src/core/bash-executor.ts +3 -84
  11. package/src/core/compaction/compaction.ts +10 -5
  12. package/src/core/extensions/index.ts +2 -0
  13. package/src/core/extensions/loader.ts +13 -1
  14. package/src/core/extensions/runner.ts +50 -2
  15. package/src/core/extensions/types.ts +67 -2
  16. package/src/core/keybindings.ts +51 -1
  17. package/src/core/prompt-templates.ts +15 -0
  18. package/src/core/python-executor-display.test.ts +42 -0
  19. package/src/core/python-executor-lifecycle.test.ts +99 -0
  20. package/src/core/python-executor-mapping.test.ts +41 -0
  21. package/src/core/python-executor-per-call.test.ts +49 -0
  22. package/src/core/python-executor-session.test.ts +103 -0
  23. package/src/core/python-executor-streaming.test.ts +77 -0
  24. package/src/core/python-executor-timeout.test.ts +35 -0
  25. package/src/core/python-executor.lifecycle.test.ts +139 -0
  26. package/src/core/python-executor.result.test.ts +49 -0
  27. package/src/core/python-executor.test.ts +180 -0
  28. package/src/core/python-executor.ts +313 -0
  29. package/src/core/python-gateway-coordinator.ts +832 -0
  30. package/src/core/python-kernel-display.test.ts +54 -0
  31. package/src/core/python-kernel-env.test.ts +138 -0
  32. package/src/core/python-kernel-session.test.ts +87 -0
  33. package/src/core/python-kernel-ws.test.ts +104 -0
  34. package/src/core/python-kernel.lifecycle.test.ts +249 -0
  35. package/src/core/python-kernel.test.ts +461 -0
  36. package/src/core/python-kernel.ts +1182 -0
  37. package/src/core/python-modules.test.ts +102 -0
  38. package/src/core/python-modules.ts +110 -0
  39. package/src/core/python-prelude.py +889 -0
  40. package/src/core/python-prelude.test.ts +140 -0
  41. package/src/core/python-prelude.ts +3 -0
  42. package/src/core/sdk.ts +24 -6
  43. package/src/core/session-manager.ts +174 -82
  44. package/src/core/settings-manager-python.test.ts +23 -0
  45. package/src/core/settings-manager.ts +202 -0
  46. package/src/core/streaming-output.test.ts +26 -0
  47. package/src/core/streaming-output.ts +100 -0
  48. package/src/core/system-prompt.python.test.ts +17 -0
  49. package/src/core/system-prompt.ts +3 -1
  50. package/src/core/timings.ts +1 -1
  51. package/src/core/tools/bash.ts +13 -2
  52. package/src/core/tools/edit-diff.ts +9 -1
  53. package/src/core/tools/index.test.ts +50 -23
  54. package/src/core/tools/index.ts +83 -1
  55. package/src/core/tools/python-execution.test.ts +68 -0
  56. package/src/core/tools/python-fallback.test.ts +72 -0
  57. package/src/core/tools/python-renderer.test.ts +36 -0
  58. package/src/core/tools/python-tool-mode.test.ts +43 -0
  59. package/src/core/tools/python.test.ts +121 -0
  60. package/src/core/tools/python.ts +760 -0
  61. package/src/core/tools/renderers.ts +2 -0
  62. package/src/core/tools/schema-validation.test.ts +1 -0
  63. package/src/core/tools/task/executor.ts +146 -3
  64. package/src/core/tools/task/worker-protocol.ts +32 -2
  65. package/src/core/tools/task/worker.ts +182 -15
  66. package/src/index.ts +6 -0
  67. package/src/main.ts +136 -40
  68. package/src/modes/interactive/components/custom-editor.ts +16 -31
  69. package/src/modes/interactive/components/extensions/extension-dashboard.ts +5 -16
  70. package/src/modes/interactive/components/extensions/extension-list.ts +5 -13
  71. package/src/modes/interactive/components/history-search.ts +5 -8
  72. package/src/modes/interactive/components/hook-editor.ts +3 -4
  73. package/src/modes/interactive/components/hook-input.ts +3 -3
  74. package/src/modes/interactive/components/hook-selector.ts +5 -15
  75. package/src/modes/interactive/components/index.ts +1 -0
  76. package/src/modes/interactive/components/keybinding-hints.ts +66 -0
  77. package/src/modes/interactive/components/model-selector.ts +53 -66
  78. package/src/modes/interactive/components/oauth-selector.ts +5 -5
  79. package/src/modes/interactive/components/session-selector.ts +29 -23
  80. package/src/modes/interactive/components/settings-defs.ts +404 -196
  81. package/src/modes/interactive/components/settings-selector.ts +14 -10
  82. package/src/modes/interactive/components/status-line-segment-editor.ts +7 -7
  83. package/src/modes/interactive/components/tool-execution.ts +8 -0
  84. package/src/modes/interactive/components/tree-selector.ts +29 -23
  85. package/src/modes/interactive/components/user-message-selector.ts +6 -17
  86. package/src/modes/interactive/controllers/command-controller.ts +86 -37
  87. package/src/modes/interactive/controllers/event-controller.ts +8 -0
  88. package/src/modes/interactive/controllers/extension-ui-controller.ts +51 -0
  89. package/src/modes/interactive/controllers/input-controller.ts +42 -6
  90. package/src/modes/interactive/interactive-mode.ts +56 -30
  91. package/src/modes/interactive/theme/theme-schema.json +2 -2
  92. package/src/modes/interactive/types.ts +6 -1
  93. package/src/modes/interactive/utils/ui-helpers.ts +2 -1
  94. package/src/modes/print-mode.ts +23 -0
  95. package/src/modes/rpc/rpc-mode.ts +21 -0
  96. package/src/prompts/agents/reviewer.md +1 -1
  97. package/src/prompts/system/system-prompt.md +32 -1
  98. package/src/prompts/tools/python.md +91 -0
@@ -1,11 +1,7 @@
1
1
  import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
2
2
  import {
3
3
  Container,
4
- isArrowLeft,
5
- isArrowRight,
6
- isEscape,
7
- isShiftTab,
8
- isTab,
4
+ matchesKey,
9
5
  type SelectItem,
10
6
  SelectList,
11
7
  type SettingItem,
@@ -118,7 +114,10 @@ class SelectSubmenu extends Container {
118
114
  type TabId = string;
119
115
 
120
116
  const SETTINGS_TABS: Tab[] = [
121
- { id: "config", label: "Config" },
117
+ { id: "behavior", label: "Behavior" },
118
+ { id: "tools", label: "Tools" },
119
+ { id: "display", label: "Display" },
120
+ { id: "voice", label: "Voice" },
122
121
  { id: "status", label: "Status" },
123
122
  { id: "lsp", label: "LSP" },
124
123
  { id: "exa", label: "Exa" },
@@ -172,7 +171,7 @@ export class SettingsSelectorComponent extends Container {
172
171
  private pluginComponent: PluginSettingsComponent | null = null;
173
172
  private statusPreviewContainer: Container | null = null;
174
173
  private statusPreviewText: Text | null = null;
175
- private currentTabId: TabId = "config";
174
+ private currentTabId: TabId = "behavior";
176
175
 
177
176
  private settingsManager: SettingsManager;
178
177
  private context: SettingsRuntimeContext;
@@ -199,7 +198,7 @@ export class SettingsSelectorComponent extends Container {
199
198
  this.addChild(new Spacer(1));
200
199
 
201
200
  // Initialize with first tab
202
- this.switchToTab("config");
201
+ this.switchToTab("behavior");
203
202
 
204
203
  // Add bottom border
205
204
  this.addChild(new DynamicBorder());
@@ -524,13 +523,18 @@ export class SettingsSelectorComponent extends Container {
524
523
 
525
524
  handleInput(data: string): void {
526
525
  // Handle tab switching first (tab, shift+tab, or left/right arrows)
527
- if (isTab(data) || isShiftTab(data) || isArrowLeft(data) || isArrowRight(data)) {
526
+ if (
527
+ matchesKey(data, "tab") ||
528
+ matchesKey(data, "shift+tab") ||
529
+ matchesKey(data, "left") ||
530
+ matchesKey(data, "right")
531
+ ) {
528
532
  this.tabBar.handleInput(data);
529
533
  return;
530
534
  }
531
535
 
532
536
  // Escape at top level cancels
533
- if (isEscape(data) && !this.currentSubmenu) {
537
+ if ((matchesKey(data, "escape") || matchesKey(data, "esc")) && !this.currentSubmenu) {
534
538
  this.callbacks.onCancel();
535
539
  return;
536
540
  }
@@ -9,7 +9,7 @@
9
9
  * - Live preview shown in the actual status line above
10
10
  */
11
11
 
12
- import { Container, isArrowDown, isArrowUp, isEnter, isEscape, isShiftTab, isTab } from "@oh-my-pi/pi-tui";
12
+ import { Container, matchesKey } from "@oh-my-pi/pi-tui";
13
13
  import type { StatusLineSegmentId } from "../../../core/settings-manager";
14
14
  import { theme } from "../theme/theme";
15
15
  import { ALL_SEGMENT_IDS } from "./status-line/segments";
@@ -109,7 +109,7 @@ export class StatusLineSegmentEditorComponent extends Container {
109
109
  handleInput(data: string): void {
110
110
  const columnSegments = this.getCurrentColumnSegments();
111
111
 
112
- if (isArrowUp(data) || data === "k") {
112
+ if (matchesKey(data, "up") || data === "k") {
113
113
  // Move selection up within column, or jump to previous column
114
114
  if (this.selectedIndex > 0) {
115
115
  this.selectedIndex--;
@@ -135,7 +135,7 @@ export class StatusLineSegmentEditorComponent extends Container {
135
135
  }
136
136
  }
137
137
  }
138
- } else if (isArrowDown(data) || data === "j") {
138
+ } else if (matchesKey(data, "down") || data === "j") {
139
139
  // Move selection down within column, or jump to next column
140
140
  if (this.selectedIndex < columnSegments.length - 1) {
141
141
  this.selectedIndex++;
@@ -161,7 +161,7 @@ export class StatusLineSegmentEditorComponent extends Container {
161
161
  }
162
162
  }
163
163
  }
164
- } else if (isTab(data)) {
164
+ } else if (matchesKey(data, "tab")) {
165
165
  // Cycle segment: left → right → disabled → left
166
166
  const seg = columnSegments[this.selectedIndex];
167
167
  if (seg) {
@@ -180,7 +180,7 @@ export class StatusLineSegmentEditorComponent extends Container {
180
180
  this.recompactColumn(oldColumn);
181
181
  this.triggerPreview();
182
182
  }
183
- } else if (isShiftTab(data)) {
183
+ } else if (matchesKey(data, "shift+tab")) {
184
184
  // Reverse cycle: left ← right ← disabled ← left
185
185
  const seg = columnSegments[this.selectedIndex];
186
186
  if (seg) {
@@ -235,11 +235,11 @@ export class StatusLineSegmentEditorComponent extends Container {
235
235
  this.selectedIndex++;
236
236
  this.triggerPreview();
237
237
  }
238
- } else if (isEnter(data)) {
238
+ } else if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") {
239
239
  const left = this.getSegmentsForColumn("left").map((s) => s.id);
240
240
  const right = this.getSegmentsForColumn("right").map((s) => s.id);
241
241
  this.callbacks.onSave(left, right);
242
- } else if (isEscape(data)) {
242
+ } else if (matchesKey(data, "escape") || matchesKey(data, "esc")) {
243
243
  this.callbacks.onCancel();
244
244
  }
245
245
  }
@@ -13,6 +13,7 @@ import {
13
13
  import stripAnsi from "strip-ansi";
14
14
  import { BASH_DEFAULT_PREVIEW_LINES } from "../../../core/tools/bash";
15
15
  import { computeEditDiff, type EditDiffError, type EditDiffResult } from "../../../core/tools/edit-diff";
16
+ import { PYTHON_DEFAULT_PREVIEW_LINES } from "../../../core/tools/python";
16
17
  import { toolRenderers } from "../../../core/tools/renderers";
17
18
  import { convertToPng } from "../../../utils/image-convert";
18
19
  import { sanitizeBinaryOutput } from "../../../utils/shell";
@@ -507,6 +508,13 @@ export class ToolExecutionComponent extends Container {
507
508
  context.output = output;
508
509
  context.expanded = this.expanded;
509
510
  context.previewLines = BASH_DEFAULT_PREVIEW_LINES;
511
+ context.timeout = typeof this.args?.timeout === "number" ? this.args.timeout : undefined;
512
+ } else if (this.toolName === "python" && this.result) {
513
+ const output = this.getTextOutput().trim();
514
+ context.output = output;
515
+ context.expanded = this.expanded;
516
+ context.previewLines = PYTHON_DEFAULT_PREVIEW_LINES;
517
+ context.timeout = typeof this.args?.timeout === "number" ? this.args.timeout : undefined;
510
518
  } else if (this.toolName === "edit") {
511
519
  // Edit needs diff preview and renderDiff function
512
520
  context.editDiffPreview = this.editDiffPreview;
@@ -2,16 +2,7 @@ import {
2
2
  type Component,
3
3
  Container,
4
4
  Input,
5
- isArrowDown,
6
- isArrowLeft,
7
- isArrowRight,
8
- isArrowUp,
9
- isBackspace,
10
- isCtrlC,
11
- isCtrlO,
12
- isEnter,
13
- isEscape,
14
- isShiftCtrlO,
5
+ matchesKey,
15
6
  Spacer,
16
7
  Text,
17
8
  TruncatedText,
@@ -660,43 +651,58 @@ class TreeList implements Component {
660
651
  }
661
652
 
662
653
  handleInput(keyData: string): void {
663
- if (isArrowUp(keyData)) {
654
+ if (matchesKey(keyData, "up")) {
664
655
  this.selectedIndex = this.selectedIndex === 0 ? this.filteredNodes.length - 1 : this.selectedIndex - 1;
665
- } else if (isArrowDown(keyData)) {
656
+ } else if (matchesKey(keyData, "down")) {
666
657
  this.selectedIndex = this.selectedIndex === this.filteredNodes.length - 1 ? 0 : this.selectedIndex + 1;
667
- } else if (isArrowLeft(keyData)) {
658
+ } else if (matchesKey(keyData, "left")) {
668
659
  // Page up
669
660
  this.selectedIndex = Math.max(0, this.selectedIndex - this.maxVisibleLines);
670
- } else if (isArrowRight(keyData)) {
661
+ } else if (matchesKey(keyData, "right")) {
671
662
  // Page down
672
663
  this.selectedIndex = Math.min(this.filteredNodes.length - 1, this.selectedIndex + this.maxVisibleLines);
673
- } else if (isEnter(keyData)) {
664
+ } else if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") {
674
665
  const selected = this.filteredNodes[this.selectedIndex];
675
666
  if (selected && this.onSelect) {
676
667
  this.onSelect(selected.node.entry.id);
677
668
  }
678
- } else if (isEscape(keyData)) {
669
+ } else if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc")) {
679
670
  if (this.searchQuery) {
680
671
  this.searchQuery = "";
681
672
  this.applyFilter();
682
673
  } else {
683
674
  this.onCancel?.();
684
675
  }
685
- } else if (isCtrlC(keyData)) {
676
+ } else if (matchesKey(keyData, "ctrl+c")) {
686
677
  this.onCancel?.();
687
- } else if (isShiftCtrlO(keyData)) {
678
+ } else if (matchesKey(keyData, "shift+ctrl+o") || matchesKey(keyData, "ctrl+shift+o")) {
688
679
  // Cycle filter backwards
689
680
  const modes: FilterMode[] = ["default", "no-tools", "user-only", "labeled-only", "all"];
690
681
  const currentIndex = modes.indexOf(this.filterMode);
691
682
  this.filterMode = modes[(currentIndex - 1 + modes.length) % modes.length];
692
683
  this.applyFilter();
693
- } else if (isCtrlO(keyData)) {
684
+ } else if (matchesKey(keyData, "ctrl+o")) {
694
685
  // Cycle filter forwards: default → no-tools → user-only → labeled-only → all → default
695
686
  const modes: FilterMode[] = ["default", "no-tools", "user-only", "labeled-only", "all"];
696
687
  const currentIndex = modes.indexOf(this.filterMode);
697
688
  this.filterMode = modes[(currentIndex + 1) % modes.length];
698
689
  this.applyFilter();
699
- } else if (isBackspace(keyData)) {
690
+ } else if (matchesKey(keyData, "alt+d")) {
691
+ this.filterMode = "default";
692
+ this.applyFilter();
693
+ } else if (matchesKey(keyData, "alt+t")) {
694
+ this.filterMode = "no-tools";
695
+ this.applyFilter();
696
+ } else if (matchesKey(keyData, "alt+u")) {
697
+ this.filterMode = "user-only";
698
+ this.applyFilter();
699
+ } else if (matchesKey(keyData, "alt+l")) {
700
+ this.filterMode = "labeled-only";
701
+ this.applyFilter();
702
+ } else if (matchesKey(keyData, "alt+a")) {
703
+ this.filterMode = "all";
704
+ this.applyFilter();
705
+ } else if (matchesKey(keyData, "backspace")) {
700
706
  if (this.searchQuery.length > 0) {
701
707
  this.searchQuery = this.searchQuery.slice(0, -1);
702
708
  this.applyFilter();
@@ -764,10 +770,10 @@ class LabelInput implements Component {
764
770
  }
765
771
 
766
772
  handleInput(keyData: string): void {
767
- if (isEnter(keyData)) {
773
+ if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") {
768
774
  const value = this.input.getValue().trim();
769
775
  this.onSubmit?.(this.entryId, value || undefined);
770
- } else if (isEscape(keyData)) {
776
+ } else if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc")) {
771
777
  this.onCancel?.();
772
778
  } else {
773
779
  this.input.handleInput(keyData);
@@ -815,7 +821,7 @@ export class TreeSelectorComponent extends Container {
815
821
  new TruncatedText(
816
822
  theme.fg(
817
823
  "muted",
818
- " Up/Down: move. Left/Right: page. l: label. Ctrl+O/Shift+Ctrl+O: filter. Type to search",
824
+ " Up/Down: move. Left/Right: page. l: label. Ctrl+O/Shift+Ctrl+O: filter. Alt+D/T/U/L/A: filter. Type to search",
819
825
  ),
820
826
  0,
821
827
  0,
@@ -1,15 +1,4 @@
1
- import {
2
- type Component,
3
- Container,
4
- isArrowDown,
5
- isArrowUp,
6
- isCtrlC,
7
- isEnter,
8
- isEscape,
9
- Spacer,
10
- Text,
11
- truncateToWidth,
12
- } from "@oh-my-pi/pi-tui";
1
+ import { type Component, Container, matchesKey, Spacer, Text, truncateToWidth } from "@oh-my-pi/pi-tui";
13
2
  import { theme } from "../theme/theme";
14
3
  import { DynamicBorder } from "./dynamic-border";
15
4
 
@@ -90,28 +79,28 @@ class UserMessageList implements Component {
90
79
 
91
80
  handleInput(keyData: string): void {
92
81
  // Up arrow - go to previous (older) message, wrap to bottom when at top
93
- if (isArrowUp(keyData)) {
82
+ if (matchesKey(keyData, "up")) {
94
83
  this.selectedIndex = this.selectedIndex === 0 ? this.messages.length - 1 : this.selectedIndex - 1;
95
84
  }
96
85
  // Down arrow - go to next (newer) message, wrap to top when at bottom
97
- else if (isArrowDown(keyData)) {
86
+ else if (matchesKey(keyData, "down")) {
98
87
  this.selectedIndex = this.selectedIndex === this.messages.length - 1 ? 0 : this.selectedIndex + 1;
99
88
  }
100
89
  // Enter - select message and branch
101
- else if (isEnter(keyData)) {
90
+ else if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") {
102
91
  const selected = this.messages[this.selectedIndex];
103
92
  if (selected && this.onSelect) {
104
93
  this.onSelect(selected.id);
105
94
  }
106
95
  }
107
96
  // Escape - cancel
108
- else if (isEscape(keyData)) {
97
+ else if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc")) {
109
98
  if (this.onCancel) {
110
99
  this.onCancel();
111
100
  }
112
101
  }
113
102
  // Ctrl+C - cancel
114
- else if (isCtrlC(keyData)) {
103
+ else if (matchesKey(keyData, "ctrl+c")) {
115
104
  if (this.onCancel) {
116
105
  this.onCancel();
117
106
  }
@@ -5,7 +5,9 @@ import { Loader, Markdown, Spacer, Text, visibleWidth } from "@oh-my-pi/pi-tui";
5
5
  import { nanoid } from "nanoid";
6
6
  import { getDebugLogPath } from "../../../config";
7
7
  import { loadCustomShare } from "../../../core/custom-share";
8
+ import type { CompactOptions } from "../../../core/extensions/types";
8
9
  import { createCompactionSummaryMessage } from "../../../core/messages";
10
+ import { getGatewayStatus } from "../../../core/python-gateway-coordinator";
9
11
  import type { TruncationResult } from "../../../core/tools/truncate";
10
12
  import { getChangelogPath, parseChangelog } from "../../../utils/changelog";
11
13
  import { copyToClipboard } from "../../../utils/clipboard";
@@ -172,40 +174,37 @@ export class CommandController {
172
174
  };
173
175
 
174
176
  try {
175
- const result = await new Promise<{ stdout: string; stderr: string; code: number | null }>((resolve) => {
176
- proc = Bun.spawn(["gh", "gist", "create", "--public=false", tmpFile], {
177
- stdout: "pipe",
178
- stderr: "pipe",
179
- });
180
- let stdout = "";
181
- let stderr = "";
182
-
183
- const stdoutReader = (proc.stdout as ReadableStream<Uint8Array>).getReader();
184
- const stderrReader = (proc.stderr as ReadableStream<Uint8Array>).getReader();
177
+ proc = Bun.spawn(["gh", "gist", "create", "--public=false", tmpFile], {
178
+ stdout: "pipe",
179
+ stderr: "pipe",
180
+ });
181
+
182
+ const readStream = async (stream: ReadableStream<Uint8Array> | null): Promise<string> => {
183
+ if (!stream) return "";
184
+ const reader = stream.getReader();
185
185
  const decoder = new TextDecoder();
186
+ let output = "";
187
+ try {
188
+ while (true) {
189
+ const { done, value } = await reader.read();
190
+ if (done) break;
191
+ output += decoder.decode(value, { stream: true });
192
+ }
193
+ } catch {
194
+ // Ignore read errors
195
+ } finally {
196
+ output += decoder.decode();
197
+ reader.releaseLock();
198
+ }
199
+ return output;
200
+ };
186
201
 
187
- (async () => {
188
- try {
189
- while (true) {
190
- const { done, value } = await stdoutReader.read();
191
- if (done) break;
192
- stdout += decoder.decode(value);
193
- }
194
- } catch {}
195
- })();
196
-
197
- (async () => {
198
- try {
199
- while (true) {
200
- const { done, value } = await stderrReader.read();
201
- if (done) break;
202
- stderr += decoder.decode(value);
203
- }
204
- } catch {}
205
- })();
206
-
207
- proc.exited.then((code) => resolve({ stdout, stderr, code }));
208
- });
202
+ const [stdout, stderr, code] = await Promise.all([
203
+ readStream(proc.stdout as ReadableStream<Uint8Array> | null),
204
+ readStream(proc.stderr as ReadableStream<Uint8Array> | null),
205
+ proc.exited.catch(() => 1),
206
+ ]);
207
+ const result = { stdout, stderr, code };
209
208
 
210
209
  if (loader.signal.aborted) return;
211
210
 
@@ -275,7 +274,47 @@ export class CommandController {
275
274
 
276
275
  if (stats.cost > 0) {
277
276
  info += `\n${theme.bold("Cost")}\n`;
278
- info += `${theme.fg("dim", "Total:")} ${stats.cost.toFixed(4)}`;
277
+ info += `${theme.fg("dim", "Total:")} ${stats.cost.toFixed(4)}\n`;
278
+ }
279
+
280
+ const gateway = getGatewayStatus();
281
+ info += `\n${theme.bold("Python Gateway")}\n`;
282
+ if (gateway.active) {
283
+ const mode = gateway.shared ? "Shared" : "Local";
284
+ info += `${theme.fg("dim", "Status:")} ${theme.fg("success", `Active (${mode})`)}\n`;
285
+ info += `${theme.fg("dim", "URL:")} ${gateway.url}\n`;
286
+ info += `${theme.fg("dim", "PID:")} ${gateway.pid}\n`;
287
+ info += `${theme.fg("dim", "Clients:")} ${gateway.refCount}\n`;
288
+ if (gateway.uptime !== null) {
289
+ const uptimeSec = Math.floor(gateway.uptime / 1000);
290
+ const mins = Math.floor(uptimeSec / 60);
291
+ const secs = uptimeSec % 60;
292
+ info += `${theme.fg("dim", "Uptime:")} ${mins}m ${secs}s\n`;
293
+ }
294
+ } else {
295
+ info += `${theme.fg("dim", "Status:")} ${theme.fg("dim", "Inactive")}\n`;
296
+ }
297
+
298
+ if (this.ctx.lspServers && this.ctx.lspServers.length > 0) {
299
+ info += `\n${theme.bold("LSP Servers")}\n`;
300
+ for (const server of this.ctx.lspServers) {
301
+ const statusColor = server.status === "ready" ? "success" : "error";
302
+ info += `${theme.fg("dim", `${server.name}:`)} ${theme.fg(statusColor, server.status)} ${theme.fg("dim", `(${server.fileTypes.join(", ")})`)}\n`;
303
+ }
304
+ }
305
+
306
+ if (this.ctx.mcpManager) {
307
+ const mcpServers = this.ctx.mcpManager.getConnectedServers();
308
+ info += `\n${theme.bold("MCP Servers")}\n`;
309
+ if (mcpServers.length === 0) {
310
+ info += `${theme.fg("dim", "None connected")}\n`;
311
+ } else {
312
+ for (const name of mcpServers) {
313
+ const conn = this.ctx.mcpManager.getConnection(name);
314
+ const toolCount = conn?.tools?.length ?? 0;
315
+ info += `${theme.fg("dim", `${name}:`)} ${theme.fg("success", "connected")} ${theme.fg("dim", `(${toolCount} tools)`)}\n`;
316
+ }
317
+ }
279
318
  }
280
319
 
281
320
  this.ctx.chatContainer.addChild(new Spacer(1));
@@ -305,6 +344,7 @@ export class CommandController {
305
344
  }
306
345
 
307
346
  handleHotkeysCommand(): void {
347
+ const expandToolsKey = this.ctx.keybindings.getDisplayString("expandTools") || "Ctrl+O";
308
348
  const hotkeys = `
309
349
  **Navigation**
310
350
  | Key | Action |
@@ -337,7 +377,7 @@ export class CommandController {
337
377
  | \`Alt+P\` | Select model (temporary) |
338
378
  | \`Ctrl+L\` | Select model (set roles) |
339
379
  | \`Ctrl+R\` | Search prompt history |
340
- | \`Ctrl+O\` | Toggle tool output expansion |
380
+ | \`${expandToolsKey}\` | Toggle tool output expansion |
341
381
  | \`Ctrl+T\` | Toggle todo list expansion |
342
382
  | \`Ctrl+G\` | Edit message in external editor |
343
383
  | \`/\` | Slash commands |
@@ -481,14 +521,18 @@ export class CommandController {
481
521
  try {
482
522
  const content = fs.readFileSync(skillPath, "utf-8");
483
523
  const body = content.replace(/^---\n[\s\S]*?\n---\n/, "").trim();
484
- const message = args ? `${body}\n\n---\n\nUser: ${args}` : body;
524
+ const metaLines = [`Skill: ${skillPath}`];
525
+ if (args) {
526
+ metaLines.push(`User: ${args}`);
527
+ }
528
+ const message = `${body}\n\n---\n\n${metaLines.join("\n")}`;
485
529
  await this.ctx.session.prompt(message);
486
530
  } catch (err) {
487
531
  this.ctx.showError(`Failed to load skill: ${err instanceof Error ? err.message : String(err)}`);
488
532
  }
489
533
  }
490
534
 
491
- async executeCompaction(customInstructions?: string, isAuto = false): Promise<void> {
535
+ async executeCompaction(customInstructionsOrOptions?: string | CompactOptions, isAuto = false): Promise<void> {
492
536
  if (this.ctx.loadingAnimation) {
493
537
  this.ctx.loadingAnimation.stop();
494
538
  this.ctx.loadingAnimation = undefined;
@@ -513,7 +557,12 @@ export class CommandController {
513
557
  this.ctx.ui.requestRender();
514
558
 
515
559
  try {
516
- const result = await this.ctx.session.compact(customInstructions);
560
+ const instructions = typeof customInstructionsOrOptions === "string" ? customInstructionsOrOptions : undefined;
561
+ const options =
562
+ customInstructionsOrOptions && typeof customInstructionsOrOptions === "object"
563
+ ? customInstructionsOrOptions
564
+ : undefined;
565
+ const result = await this.ctx.session.compact(instructions, options);
517
566
 
518
567
  this.ctx.rebuildChatFromMessages();
519
568
 
@@ -12,6 +12,7 @@ import type { InteractiveModeContext, TodoItem } from "../types";
12
12
  export class EventController {
13
13
  private lastReadGroup: ReadToolGroupComponent | undefined = undefined;
14
14
  private lastThinkingCount = 0;
15
+ private renderedCustomMessages = new Set<string>();
15
16
 
16
17
  constructor(private ctx: InteractiveModeContext) {}
17
18
 
@@ -73,6 +74,11 @@ export class EventController {
73
74
 
74
75
  case "message_start":
75
76
  if (event.message.role === "hookMessage" || event.message.role === "custom") {
77
+ const signature = `${event.message.role}:${event.message.customType}:${event.message.timestamp}`;
78
+ if (this.renderedCustomMessages.has(signature)) {
79
+ break;
80
+ }
81
+ this.renderedCustomMessages.add(signature);
76
82
  this.resetReadGroup();
77
83
  this.ctx.addMessageToChat(event.message);
78
84
  this.ctx.ui.requestRender();
@@ -324,6 +330,8 @@ export class EventController {
324
330
  });
325
331
  this.ctx.statusLine.invalidate();
326
332
  this.ctx.updateEditorTopBorder();
333
+ } else {
334
+ this.ctx.showWarning("Auto-compaction failed; continuing without compaction");
327
335
  }
328
336
  await this.ctx.flushCompactionQueue({ willRetry: event.willRetry });
329
337
  this.ctx.ui.requestRender();
@@ -87,6 +87,9 @@ export class ExtensionUiController {
87
87
  appendEntry: (customType, data) => {
88
88
  this.ctx.sessionManager.appendCustomEntry(customType, data);
89
89
  },
90
+ setLabel: (targetId, label) => {
91
+ this.ctx.sessionManager.appendLabelChange(targetId, label);
92
+ },
90
93
  getActiveTools: () => this.ctx.session.getActiveToolNames(),
91
94
  getAllTools: () => this.ctx.session.getAllToolNames(),
92
95
  setActiveTools: (toolNames) => this.ctx.session.setActiveToolsByName(toolNames),
@@ -107,8 +110,16 @@ export class ExtensionUiController {
107
110
  shutdown: () => {
108
111
  // Signal shutdown request (will be handled by main loop)
109
112
  },
113
+ getContextUsage: () => this.ctx.session.getContextUsage(),
114
+ compact: async (instructionsOrOptions) => {
115
+ const instructions = typeof instructionsOrOptions === "string" ? instructionsOrOptions : undefined;
116
+ const options =
117
+ instructionsOrOptions && typeof instructionsOrOptions === "object" ? instructionsOrOptions : undefined;
118
+ await this.ctx.session.compact(instructions, options);
119
+ },
110
120
  };
111
121
  const commandActions: ExtensionCommandContextActions = {
122
+ getContextUsage: () => this.ctx.session.getContextUsage(),
112
123
  waitForIdle: () => this.ctx.session.agent.waitForIdle(),
113
124
  newSession: async (options) => {
114
125
  // Stop any loading animation
@@ -178,6 +189,16 @@ export class ExtensionUiController {
178
189
 
179
190
  return { cancelled: false };
180
191
  },
192
+ compact: async (instructionsOrOptions) => {
193
+ const instructions = typeof instructionsOrOptions === "string" ? instructionsOrOptions : undefined;
194
+ const options =
195
+ instructionsOrOptions && typeof instructionsOrOptions === "object" ? instructionsOrOptions : undefined;
196
+ if (this.ctx.isBackgrounded) {
197
+ await this.ctx.session.compact(instructions, options);
198
+ return;
199
+ }
200
+ await this.ctx.executeCompaction(instructionsOrOptions, false);
201
+ },
181
202
  };
182
203
 
183
204
  extensionRunner.initialize(actions, contextActions, commandActions, uiContext);
@@ -235,6 +256,9 @@ export class ExtensionUiController {
235
256
  appendEntry: (customType, data) => {
236
257
  this.ctx.sessionManager.appendCustomEntry(customType, data);
237
258
  },
259
+ setLabel: (targetId, label) => {
260
+ this.ctx.sessionManager.appendLabelChange(targetId, label);
261
+ },
238
262
  getActiveTools: () => this.ctx.session.getActiveToolNames(),
239
263
  getAllTools: () => this.ctx.session.getAllToolNames(),
240
264
  setActiveTools: (toolNames) => this.ctx.session.setActiveToolsByName(toolNames),
@@ -255,8 +279,16 @@ export class ExtensionUiController {
255
279
  shutdown: () => {
256
280
  // Signal shutdown request (will be handled by main loop)
257
281
  },
282
+ getContextUsage: () => this.ctx.session.getContextUsage(),
283
+ compact: async (instructionsOrOptions) => {
284
+ const instructions = typeof instructionsOrOptions === "string" ? instructionsOrOptions : undefined;
285
+ const options =
286
+ instructionsOrOptions && typeof instructionsOrOptions === "object" ? instructionsOrOptions : undefined;
287
+ await this.ctx.session.compact(instructions, options);
288
+ },
258
289
  };
259
290
  const commandActions: ExtensionCommandContextActions = {
291
+ getContextUsage: () => this.ctx.session.getContextUsage(),
260
292
  waitForIdle: () => this.ctx.session.agent.waitForIdle(),
261
293
  newSession: async (options) => {
262
294
  if (this.ctx.isBackgrounded) {
@@ -335,6 +367,16 @@ export class ExtensionUiController {
335
367
 
336
368
  return { cancelled: false };
337
369
  },
370
+ compact: async (instructionsOrOptions) => {
371
+ const instructions = typeof instructionsOrOptions === "string" ? instructionsOrOptions : undefined;
372
+ const options =
373
+ instructionsOrOptions && typeof instructionsOrOptions === "object" ? instructionsOrOptions : undefined;
374
+ if (this.ctx.isBackgrounded) {
375
+ await this.ctx.session.compact(instructions, options);
376
+ return;
377
+ }
378
+ await this.ctx.executeCompaction(instructionsOrOptions, false);
379
+ },
338
380
  };
339
381
 
340
382
  extensionRunner.initialize(actions, contextActions, commandActions, uiContext);
@@ -382,6 +424,15 @@ export class ExtensionUiController {
382
424
  try {
383
425
  await registeredTool.definition.onSession(event, {
384
426
  ui: uiContext,
427
+ getContextUsage: () => this.ctx.session.getContextUsage(),
428
+ compact: async (instructionsOrOptions) => {
429
+ const instructions = typeof instructionsOrOptions === "string" ? instructionsOrOptions : undefined;
430
+ const options =
431
+ instructionsOrOptions && typeof instructionsOrOptions === "object"
432
+ ? instructionsOrOptions
433
+ : undefined;
434
+ await this.ctx.session.compact(instructions, options);
435
+ },
385
436
  hasUI: !this.ctx.isBackgrounded,
386
437
  cwd: this.ctx.sessionManager.getCwd(),
387
438
  sessionManager: this.ctx.session.sessionManager,