@mariozechner/pi-coding-agent 0.12.6 → 0.12.8

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.
@@ -3,13 +3,16 @@ import * as path from "node:path";
3
3
  import { CombinedAutocompleteProvider, Container, Input, Loader, Markdown, ProcessTerminal, Spacer, Text, TruncatedText, TUI, visibleWidth, } from "@mariozechner/pi-tui";
4
4
  import { exec } from "child_process";
5
5
  import { getChangelogPath, parseChangelog } from "../changelog.js";
6
+ import { calculateContextTokens, compact, shouldCompact } from "../compaction.js";
6
7
  import { APP_NAME, getDebugLogPath, getModelsPath, getOAuthPath } from "../config.js";
7
8
  import { exportSessionToHtml } from "../export-html.js";
8
9
  import { getApiKeyForModel, getAvailableModels, invalidateOAuthCache } from "../model-config.js";
9
10
  import { listOAuthProviders, login, logout } from "../oauth/index.js";
11
+ import { getLatestCompactionEntry, loadSessionFromEntries, SUMMARY_PREFIX, SUMMARY_SUFFIX, } from "../session-manager.js";
10
12
  import { expandSlashCommand, loadSlashCommands } from "../slash-commands.js";
11
13
  import { getEditorTheme, getMarkdownTheme, onThemeChange, setTheme, theme } from "../theme/theme.js";
12
14
  import { AssistantMessageComponent } from "./assistant-message.js";
15
+ import { CompactionComponent } from "./compaction.js";
13
16
  import { CustomEditor } from "./custom-editor.js";
14
17
  import { DynamicBorder } from "./dynamic-border.js";
15
18
  import { FooterComponent } from "./footer.js";
@@ -86,6 +89,7 @@ export class TuiRenderer {
86
89
  this.editorContainer = new Container(); // Container to hold editor or selector
87
90
  this.editorContainer.addChild(this.editor); // Start with editor
88
91
  this.footer = new FooterComponent(agent.state);
92
+ this.footer.setAutoCompactEnabled(this.settingsManager.getCompactionEnabled());
89
93
  // Define slash commands
90
94
  const thinkingCommand = {
91
95
  name: "thinking",
@@ -131,6 +135,14 @@ export class TuiRenderer {
131
135
  name: "clear",
132
136
  description: "Clear context and start a fresh session",
133
137
  };
138
+ const compactCommand = {
139
+ name: "compact",
140
+ description: "Manually compact the session context",
141
+ };
142
+ const autocompactCommand = {
143
+ name: "autocompact",
144
+ description: "Toggle automatic context compaction",
145
+ };
134
146
  // Load file-based slash commands
135
147
  this.fileCommands = loadSlashCommands();
136
148
  // Convert file commands to SlashCommand format
@@ -151,6 +163,8 @@ export class TuiRenderer {
151
163
  logoutCommand,
152
164
  queueCommand,
153
165
  clearCommand,
166
+ compactCommand,
167
+ autocompactCommand,
154
168
  ...fileSlashCommands,
155
169
  ], process.cwd(), fdPath);
156
170
  this.editor.setAutocompleteProvider(autocompleteProvider);
@@ -322,6 +336,19 @@ export class TuiRenderer {
322
336
  this.editor.setText("");
323
337
  return;
324
338
  }
339
+ // Check for /compact command
340
+ if (text === "/compact" || text.startsWith("/compact ")) {
341
+ const customInstructions = text.startsWith("/compact ") ? text.slice(9).trim() : undefined;
342
+ this.handleCompactCommand(customInstructions);
343
+ this.editor.setText("");
344
+ return;
345
+ }
346
+ // Check for /autocompact command
347
+ if (text === "/autocompact") {
348
+ this.handleAutocompactCommand();
349
+ this.editor.setText("");
350
+ return;
351
+ }
325
352
  // Check for /debug command
326
353
  if (text === "/debug") {
327
354
  this.handleDebugCommand();
@@ -396,9 +423,39 @@ export class TuiRenderer {
396
423
  if (this.sessionManager.shouldInitializeSession(this.agent.state.messages)) {
397
424
  this.sessionManager.startSession(this.agent.state);
398
425
  }
426
+ // Check for auto-compaction after assistant messages
427
+ if (event.message.role === "assistant") {
428
+ await this.checkAutoCompaction();
429
+ }
399
430
  }
400
431
  });
401
432
  }
433
+ async checkAutoCompaction() {
434
+ const settings = this.settingsManager.getCompactionSettings();
435
+ if (!settings.enabled)
436
+ return;
437
+ // Get last non-aborted assistant message from agent state
438
+ const messages = this.agent.state.messages;
439
+ let lastAssistant = null;
440
+ for (let i = messages.length - 1; i >= 0; i--) {
441
+ const msg = messages[i];
442
+ if (msg.role === "assistant") {
443
+ const assistantMsg = msg;
444
+ if (assistantMsg.stopReason !== "aborted") {
445
+ lastAssistant = assistantMsg;
446
+ break;
447
+ }
448
+ }
449
+ }
450
+ if (!lastAssistant)
451
+ return;
452
+ const contextTokens = calculateContextTokens(lastAssistant.usage);
453
+ const contextWindow = this.agent.state.model.contextWindow;
454
+ if (!shouldCompact(contextTokens, contextWindow, settings))
455
+ return;
456
+ // Trigger auto-compaction
457
+ await this.executeCompaction(undefined, true);
458
+ }
402
459
  async handleEvent(event, state) {
403
460
  if (!this.isInitialized) {
404
461
  await this.init();
@@ -422,7 +479,9 @@ export class TuiRenderer {
422
479
  if (event.message.role === "user") {
423
480
  // Check if this is a queued message
424
481
  const userMsg = event.message;
425
- const textBlocks = userMsg.content.filter((c) => c.type === "text");
482
+ const textBlocks = typeof userMsg.content === "string"
483
+ ? [{ type: "text", text: userMsg.content }]
484
+ : userMsg.content.filter((c) => c.type === "text");
426
485
  const messageText = textBlocks.map((c) => c.text).join("");
427
486
  const queuedIndex = this.queuedMessages.indexOf(messageText);
428
487
  if (queuedIndex !== -1) {
@@ -550,7 +609,9 @@ export class TuiRenderer {
550
609
  if (message.role === "user") {
551
610
  const userMsg = message;
552
611
  // Extract text content from content blocks
553
- const textBlocks = userMsg.content.filter((c) => c.type === "text");
612
+ const textBlocks = typeof userMsg.content === "string"
613
+ ? [{ type: "text", text: userMsg.content }]
614
+ : userMsg.content.filter((c) => c.type === "text");
554
615
  const textContent = textBlocks.map((c) => c.text).join("");
555
616
  if (textContent) {
556
617
  const userComponent = new UserMessageComponent(textContent, this.isFirstUserMessage);
@@ -574,17 +635,30 @@ export class TuiRenderer {
574
635
  this.footer.updateState(state);
575
636
  // Update editor border color based on current thinking level
576
637
  this.updateEditorBorderColor();
638
+ // Get compaction info if any
639
+ const compactionEntry = getLatestCompactionEntry(this.sessionManager.loadEntries());
577
640
  // Render messages
578
641
  for (let i = 0; i < state.messages.length; i++) {
579
642
  const message = state.messages[i];
580
643
  if (message.role === "user") {
581
644
  const userMsg = message;
582
- const textBlocks = userMsg.content.filter((c) => c.type === "text");
645
+ const textBlocks = typeof userMsg.content === "string"
646
+ ? [{ type: "text", text: userMsg.content }]
647
+ : userMsg.content.filter((c) => c.type === "text");
583
648
  const textContent = textBlocks.map((c) => c.text).join("");
584
649
  if (textContent) {
585
- const userComponent = new UserMessageComponent(textContent, this.isFirstUserMessage);
586
- this.chatContainer.addChild(userComponent);
587
- this.isFirstUserMessage = false;
650
+ // Check if this is a compaction summary message
651
+ if (textContent.startsWith(SUMMARY_PREFIX) && compactionEntry) {
652
+ const summary = textContent.slice(SUMMARY_PREFIX.length, -SUMMARY_SUFFIX.length);
653
+ const component = new CompactionComponent(compactionEntry.tokensBefore, summary);
654
+ component.setExpanded(this.toolOutputExpanded);
655
+ this.chatContainer.addChild(component);
656
+ }
657
+ else {
658
+ const userComponent = new UserMessageComponent(textContent, this.isFirstUserMessage);
659
+ this.chatContainer.addChild(userComponent);
660
+ this.isFirstUserMessage = false;
661
+ }
588
662
  }
589
663
  }
590
664
  else if (message.role === "assistant") {
@@ -639,6 +713,61 @@ export class TuiRenderer {
639
713
  };
640
714
  });
641
715
  }
716
+ rebuildChatFromMessages() {
717
+ // Reset state and re-render messages from agent state
718
+ this.isFirstUserMessage = true;
719
+ this.pendingTools.clear();
720
+ // Get compaction info if any
721
+ const compactionEntry = getLatestCompactionEntry(this.sessionManager.loadEntries());
722
+ for (const message of this.agent.state.messages) {
723
+ if (message.role === "user") {
724
+ const userMsg = message;
725
+ const textBlocks = typeof userMsg.content === "string"
726
+ ? [{ type: "text", text: userMsg.content }]
727
+ : userMsg.content.filter((c) => c.type === "text");
728
+ const textContent = textBlocks.map((c) => c.text).join("");
729
+ if (textContent) {
730
+ // Check if this is a compaction summary message
731
+ if (textContent.startsWith(SUMMARY_PREFIX) && compactionEntry) {
732
+ const summary = textContent.slice(SUMMARY_PREFIX.length, -SUMMARY_SUFFIX.length);
733
+ const component = new CompactionComponent(compactionEntry.tokensBefore, summary);
734
+ component.setExpanded(this.toolOutputExpanded);
735
+ this.chatContainer.addChild(component);
736
+ }
737
+ else {
738
+ const userComponent = new UserMessageComponent(textContent, this.isFirstUserMessage);
739
+ this.chatContainer.addChild(userComponent);
740
+ this.isFirstUserMessage = false;
741
+ }
742
+ }
743
+ }
744
+ else if (message.role === "assistant") {
745
+ const assistantMsg = message;
746
+ const assistantComponent = new AssistantMessageComponent(assistantMsg);
747
+ this.chatContainer.addChild(assistantComponent);
748
+ for (const content of assistantMsg.content) {
749
+ if (content.type === "toolCall") {
750
+ const component = new ToolExecutionComponent(content.name, content.arguments);
751
+ this.chatContainer.addChild(component);
752
+ this.pendingTools.set(content.id, component);
753
+ }
754
+ }
755
+ }
756
+ else if (message.role === "toolResult") {
757
+ const component = this.pendingTools.get(message.toolCallId);
758
+ if (component) {
759
+ component.updateResult({
760
+ content: message.content,
761
+ details: message.details,
762
+ isError: message.isError,
763
+ });
764
+ this.pendingTools.delete(message.toolCallId);
765
+ }
766
+ }
767
+ }
768
+ this.pendingTools.clear();
769
+ this.ui.requestRender();
770
+ }
642
771
  handleCtrlC() {
643
772
  // Handle Ctrl+C double-press logic
644
773
  const now = Date.now();
@@ -771,11 +900,14 @@ export class TuiRenderer {
771
900
  }
772
901
  toggleToolOutputExpansion() {
773
902
  this.toolOutputExpanded = !this.toolOutputExpanded;
774
- // Update all tool execution components
903
+ // Update all tool execution and compaction components
775
904
  for (const child of this.chatContainer.children) {
776
905
  if (child instanceof ToolExecutionComponent) {
777
906
  child.setExpanded(this.toolOutputExpanded);
778
907
  }
908
+ else if (child instanceof CompactionComponent) {
909
+ child.setExpanded(this.toolOutputExpanded);
910
+ }
779
911
  }
780
912
  this.ui.requestRender();
781
913
  }
@@ -795,6 +927,14 @@ export class TuiRenderer {
795
927
  this.chatContainer.addChild(new Text(theme.fg("warning", `Warning: ${warningMessage}`), 1, 0));
796
928
  this.ui.requestRender();
797
929
  }
930
+ showSuccess(message, detail) {
931
+ this.chatContainer.addChild(new Spacer(1));
932
+ const text = detail
933
+ ? `${theme.fg("success", message)}\n${theme.fg("muted", detail)}`
934
+ : theme.fg("success", message);
935
+ this.chatContainer.addChild(new Text(text, 1, 1));
936
+ this.ui.requestRender();
937
+ }
798
938
  showThinkingSelector() {
799
939
  // Create thinking selector with current level
800
940
  this.thinkingSelector = new ThinkingSelectorComponent(this.agent.state.thinkingLevel, (level) => {
@@ -945,17 +1085,30 @@ export class TuiRenderer {
945
1085
  this.ui.setFocus(this.editor);
946
1086
  }
947
1087
  showUserMessageSelector() {
948
- // Extract all user messages from the current state
1088
+ // Read from session file directly to see ALL historical user messages
1089
+ // (including those before compaction events)
1090
+ const entries = this.sessionManager.loadEntries();
949
1091
  const userMessages = [];
950
- for (let i = 0; i < this.agent.state.messages.length; i++) {
951
- const message = this.agent.state.messages[i];
952
- if (message.role === "user") {
953
- const userMsg = message;
954
- const textBlocks = userMsg.content.filter((c) => c.type === "text");
955
- const textContent = textBlocks.map((c) => c.text).join("");
956
- if (textContent) {
957
- userMessages.push({ index: i, text: textContent });
958
- }
1092
+ const getUserMessageText = (content) => {
1093
+ if (typeof content === "string")
1094
+ return content;
1095
+ if (Array.isArray(content)) {
1096
+ return content
1097
+ .filter((c) => c.type === "text")
1098
+ .map((c) => c.text)
1099
+ .join("");
1100
+ }
1101
+ return "";
1102
+ };
1103
+ for (let i = 0; i < entries.length; i++) {
1104
+ const entry = entries[i];
1105
+ if (entry.type !== "message")
1106
+ continue;
1107
+ if (entry.message.role !== "user")
1108
+ continue;
1109
+ const textContent = getUserMessageText(entry.message.content);
1110
+ if (textContent) {
1111
+ userMessages.push({ index: i, text: textContent });
959
1112
  }
960
1113
  }
961
1114
  // Don't show selector if there are no messages or only one message
@@ -966,26 +1119,28 @@ export class TuiRenderer {
966
1119
  return;
967
1120
  }
968
1121
  // Create user message selector
969
- this.userMessageSelector = new UserMessageSelectorComponent(userMessages, (messageIndex) => {
1122
+ this.userMessageSelector = new UserMessageSelectorComponent(userMessages, (entryIndex) => {
970
1123
  // Get the selected user message text to put in the editor
971
- const selectedMessage = this.agent.state.messages[messageIndex];
972
- const selectedUserMsg = selectedMessage;
973
- const textBlocks = selectedUserMsg.content.filter((c) => c.type === "text");
974
- const selectedText = textBlocks.map((c) => c.text).join("");
975
- // Create a branched session with messages UP TO (but not including) the selected message
976
- const newSessionFile = this.sessionManager.createBranchedSession(this.agent.state, messageIndex - 1);
1124
+ const selectedEntry = entries[entryIndex];
1125
+ if (selectedEntry.type !== "message")
1126
+ return;
1127
+ if (selectedEntry.message.role !== "user")
1128
+ return;
1129
+ const selectedText = getUserMessageText(selectedEntry.message.content);
1130
+ // Create a branched session by copying entries up to (but not including) the selected entry
1131
+ const newSessionFile = this.sessionManager.createBranchedSessionFromEntries(entries, entryIndex);
977
1132
  // Set the new session file as active
978
1133
  this.sessionManager.setSessionFile(newSessionFile);
979
- // Truncate messages in agent state to before the selected message
980
- const truncatedMessages = this.agent.state.messages.slice(0, messageIndex);
981
- this.agent.replaceMessages(truncatedMessages);
1134
+ // Reload the session
1135
+ const loaded = loadSessionFromEntries(this.sessionManager.loadEntries());
1136
+ this.agent.replaceMessages(loaded.messages);
982
1137
  // Clear and re-render the chat
983
1138
  this.chatContainer.clear();
984
1139
  this.isFirstUserMessage = true;
985
1140
  this.renderInitialMessages(this.agent.state);
986
1141
  // Show confirmation message
987
1142
  this.chatContainer.addChild(new Spacer(1));
988
- this.chatContainer.addChild(new Text(theme.fg("dim", `Branched to new session from message ${messageIndex}`), 1, 0));
1143
+ this.chatContainer.addChild(new Text(theme.fg("dim", "Branched to new session"), 1, 0));
989
1144
  // Put the selected message in the editor
990
1145
  this.editor.setText(selectedText);
991
1146
  // Hide selector and show editor again
@@ -1260,6 +1415,109 @@ export class TuiRenderer {
1260
1415
  this.chatContainer.addChild(new Text(theme.fg("accent", "✓ Debug log written") + "\n" + theme.fg("muted", debugLogPath), 1, 1));
1261
1416
  this.ui.requestRender();
1262
1417
  }
1418
+ compactionAbortController = null;
1419
+ /**
1420
+ * Shared logic to execute context compaction.
1421
+ * Handles aborting agent, showing loader, performing compaction, updating session/UI.
1422
+ */
1423
+ async executeCompaction(customInstructions, isAuto = false) {
1424
+ // Unsubscribe first to prevent processing events during compaction
1425
+ this.unsubscribe?.();
1426
+ // Abort and wait for completion
1427
+ this.agent.abort();
1428
+ await this.agent.waitForIdle();
1429
+ // Stop loading animation
1430
+ if (this.loadingAnimation) {
1431
+ this.loadingAnimation.stop();
1432
+ this.loadingAnimation = null;
1433
+ }
1434
+ this.statusContainer.clear();
1435
+ // Create abort controller for compaction
1436
+ this.compactionAbortController = new AbortController();
1437
+ // Set up escape handler during compaction
1438
+ const originalOnEscape = this.editor.onEscape;
1439
+ this.editor.onEscape = () => {
1440
+ if (this.compactionAbortController) {
1441
+ this.compactionAbortController.abort();
1442
+ }
1443
+ };
1444
+ // Show compacting status with loader
1445
+ this.chatContainer.addChild(new Spacer(1));
1446
+ const label = isAuto ? "Auto-compacting context... (esc to cancel)" : "Compacting context... (esc to cancel)";
1447
+ const compactingLoader = new Loader(this.ui, (spinner) => theme.fg("accent", spinner), (text) => theme.fg("muted", text), label);
1448
+ this.statusContainer.addChild(compactingLoader);
1449
+ this.ui.requestRender();
1450
+ try {
1451
+ // Get API key for current model
1452
+ const apiKey = await getApiKeyForModel(this.agent.state.model);
1453
+ if (!apiKey) {
1454
+ throw new Error(`No API key for ${this.agent.state.model.provider}`);
1455
+ }
1456
+ // Perform compaction with abort signal
1457
+ const entries = this.sessionManager.loadEntries();
1458
+ const settings = this.settingsManager.getCompactionSettings();
1459
+ const compactionEntry = await compact(entries, this.agent.state.model, settings, apiKey, this.compactionAbortController.signal, customInstructions);
1460
+ // Check if aborted after compact returned
1461
+ if (this.compactionAbortController.signal.aborted) {
1462
+ throw new Error("Compaction cancelled");
1463
+ }
1464
+ // Save compaction to session
1465
+ this.sessionManager.saveCompaction(compactionEntry);
1466
+ // Reload session
1467
+ const loaded = loadSessionFromEntries(this.sessionManager.loadEntries());
1468
+ this.agent.replaceMessages(loaded.messages);
1469
+ // Rebuild UI
1470
+ this.chatContainer.clear();
1471
+ this.rebuildChatFromMessages();
1472
+ // Add compaction component at current position so user can see/expand the summary
1473
+ const compactionComponent = new CompactionComponent(compactionEntry.tokensBefore, compactionEntry.summary);
1474
+ compactionComponent.setExpanded(this.toolOutputExpanded);
1475
+ this.chatContainer.addChild(compactionComponent);
1476
+ // Update footer with new state (fixes context % display)
1477
+ this.footer.updateState(this.agent.state);
1478
+ // Show success message
1479
+ const successTitle = isAuto ? "✓ Context auto-compacted" : "✓ Context compacted";
1480
+ this.showSuccess(successTitle, `Reduced from ${compactionEntry.tokensBefore.toLocaleString()} tokens`);
1481
+ }
1482
+ catch (error) {
1483
+ const message = error instanceof Error ? error.message : String(error);
1484
+ if (message === "Compaction cancelled" || (error instanceof Error && error.name === "AbortError")) {
1485
+ this.showError("Compaction cancelled");
1486
+ }
1487
+ else {
1488
+ this.showError(`Compaction failed: ${message}`);
1489
+ }
1490
+ }
1491
+ finally {
1492
+ // Clean up
1493
+ compactingLoader.stop();
1494
+ this.statusContainer.clear();
1495
+ this.compactionAbortController = null;
1496
+ this.editor.onEscape = originalOnEscape;
1497
+ }
1498
+ // Resubscribe to agent
1499
+ this.subscribeToAgent();
1500
+ }
1501
+ async handleCompactCommand(customInstructions) {
1502
+ // Check if there are any messages to compact
1503
+ const entries = this.sessionManager.loadEntries();
1504
+ const messageCount = entries.filter((e) => e.type === "message").length;
1505
+ if (messageCount < 2) {
1506
+ this.showWarning("Nothing to compact (no messages yet)");
1507
+ return;
1508
+ }
1509
+ await this.executeCompaction(customInstructions, false);
1510
+ }
1511
+ handleAutocompactCommand() {
1512
+ const currentEnabled = this.settingsManager.getCompactionEnabled();
1513
+ const newState = !currentEnabled;
1514
+ this.settingsManager.setCompactionEnabled(newState);
1515
+ this.footer.setAutoCompactEnabled(newState);
1516
+ // Show brief notification (same style as thinking level toggle)
1517
+ this.chatContainer.addChild(new Spacer(1));
1518
+ this.chatContainer.addChild(new Text(theme.fg("dim", `Auto-compaction: ${newState ? "on" : "off"}`), 1, 0));
1519
+ this.ui.requestRender();
1520
+ }
1263
1521
  updatePendingMessagesDisplay() {
1264
1522
  this.pendingMessagesContainer.clear();
1265
1523
  if (this.queuedMessages.length > 0) {