@mariozechner/pi-coding-agent 0.13.1 → 0.14.1

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 (68) hide show
  1. package/CHANGELOG.md +33 -1
  2. package/README.md +62 -0
  3. package/dist/compaction.d.ts.map +1 -1
  4. package/dist/compaction.js +5 -2
  5. package/dist/compaction.js.map +1 -1
  6. package/dist/export-html.d.ts.map +1 -1
  7. package/dist/export-html.js +52 -25
  8. package/dist/export-html.js.map +1 -1
  9. package/dist/main.d.ts.map +1 -1
  10. package/dist/main.js +120 -9
  11. package/dist/main.js.map +1 -1
  12. package/dist/messages.d.ts +43 -0
  13. package/dist/messages.d.ts.map +1 -0
  14. package/dist/messages.js +71 -0
  15. package/dist/messages.js.map +1 -0
  16. package/dist/model-config.d.ts.map +1 -1
  17. package/dist/model-config.js +9 -0
  18. package/dist/model-config.js.map +1 -1
  19. package/dist/settings-manager.d.ts +6 -3
  20. package/dist/settings-manager.d.ts.map +1 -1
  21. package/dist/settings-manager.js +7 -0
  22. package/dist/settings-manager.js.map +1 -1
  23. package/dist/shell.d.ts +16 -0
  24. package/dist/shell.d.ts.map +1 -0
  25. package/dist/shell.js +108 -0
  26. package/dist/shell.js.map +1 -0
  27. package/dist/theme/dark.json +4 -1
  28. package/dist/theme/light.json +4 -1
  29. package/dist/theme/theme-schema.json +28 -0
  30. package/dist/theme/theme.d.ts +3 -2
  31. package/dist/theme/theme.d.ts.map +1 -1
  32. package/dist/theme/theme.js +32 -3
  33. package/dist/theme/theme.js.map +1 -1
  34. package/dist/tools/bash.d.ts.map +1 -1
  35. package/dist/tools/bash.js +87 -153
  36. package/dist/tools/bash.js.map +1 -1
  37. package/dist/tools/find.d.ts.map +1 -1
  38. package/dist/tools/find.js +49 -28
  39. package/dist/tools/find.js.map +1 -1
  40. package/dist/tools/grep.d.ts.map +1 -1
  41. package/dist/tools/grep.js +37 -9
  42. package/dist/tools/grep.js.map +1 -1
  43. package/dist/tools/ls.d.ts.map +1 -1
  44. package/dist/tools/ls.js +28 -10
  45. package/dist/tools/ls.js.map +1 -1
  46. package/dist/tools/read.d.ts.map +1 -1
  47. package/dist/tools/read.js +52 -31
  48. package/dist/tools/read.js.map +1 -1
  49. package/dist/tools/truncate.d.ts +66 -0
  50. package/dist/tools/truncate.d.ts.map +1 -0
  51. package/dist/tools/truncate.js +195 -0
  52. package/dist/tools/truncate.js.map +1 -0
  53. package/dist/tui/bash-execution.d.ts +33 -0
  54. package/dist/tui/bash-execution.d.ts.map +1 -0
  55. package/dist/tui/bash-execution.js +134 -0
  56. package/dist/tui/bash-execution.js.map +1 -0
  57. package/dist/tui/tool-execution.d.ts.map +1 -1
  58. package/dist/tui/tool-execution.js +81 -5
  59. package/dist/tui/tool-execution.js.map +1 -1
  60. package/dist/tui/tui-renderer.d.ts +7 -1
  61. package/dist/tui/tui-renderer.d.ts.map +1 -1
  62. package/dist/tui/tui-renderer.js +225 -10
  63. package/dist/tui/tui-renderer.js.map +1 -1
  64. package/package.json +4 -4
  65. package/dist/fuzzy.test.d.ts +0 -2
  66. package/dist/fuzzy.test.d.ts.map +0 -1
  67. package/dist/fuzzy.test.js +0 -76
  68. package/dist/fuzzy.test.js.map +0 -1
@@ -1,18 +1,27 @@
1
+ import { randomBytes } from "node:crypto";
1
2
  import * as fs from "node:fs";
3
+ import { createWriteStream } from "node:fs";
4
+ import { tmpdir } from "node:os";
2
5
  import * as path from "node:path";
6
+ import { join } from "node:path";
3
7
  import { CombinedAutocompleteProvider, Container, Input, Loader, Markdown, ProcessTerminal, Spacer, Text, TruncatedText, TUI, visibleWidth, } from "@mariozechner/pi-tui";
4
- import { exec } from "child_process";
8
+ import { exec, spawn } from "child_process";
9
+ import stripAnsi from "strip-ansi";
5
10
  import { getChangelogPath, parseChangelog } from "../changelog.js";
6
11
  import { copyToClipboard } from "../clipboard.js";
7
12
  import { calculateContextTokens, compact, shouldCompact } from "../compaction.js";
8
13
  import { APP_NAME, getDebugLogPath, getModelsPath, getOAuthPath } from "../config.js";
9
14
  import { exportSessionToHtml } from "../export-html.js";
15
+ import { isBashExecutionMessage } from "../messages.js";
10
16
  import { getApiKeyForModel, getAvailableModels, invalidateOAuthCache } from "../model-config.js";
11
17
  import { listOAuthProviders, login, logout } from "../oauth/index.js";
12
18
  import { getLatestCompactionEntry, loadSessionFromEntries, SUMMARY_PREFIX, SUMMARY_SUFFIX, } from "../session-manager.js";
19
+ import { getShellConfig, killProcessTree } from "../shell.js";
13
20
  import { expandSlashCommand, loadSlashCommands } from "../slash-commands.js";
14
21
  import { getEditorTheme, getMarkdownTheme, onThemeChange, setTheme, theme } from "../theme/theme.js";
22
+ import { DEFAULT_MAX_BYTES, truncateTail } from "../tools/truncate.js";
15
23
  import { AssistantMessageComponent } from "./assistant-message.js";
24
+ import { BashExecutionComponent } from "./bash-execution.js";
16
25
  import { CompactionComponent } from "./compaction.js";
17
26
  import { CustomEditor } from "./custom-editor.js";
18
27
  import { DynamicBorder } from "./dynamic-border.js";
@@ -47,6 +56,7 @@ export class TuiRenderer {
47
56
  lastSigintTime = 0;
48
57
  lastEscapeTime = 0;
49
58
  changelogMarkdown = null;
59
+ collapseChangelog = false;
50
60
  // Message queueing
51
61
  queuedMessages = [];
52
62
  // Streaming message tracking
@@ -79,12 +89,19 @@ export class TuiRenderer {
79
89
  unsubscribe;
80
90
  // File-based slash commands
81
91
  fileCommands = [];
82
- constructor(agent, sessionManager, settingsManager, version, changelogMarkdown = null, scopedModels = [], fdPath = null) {
92
+ // Track if editor is in bash mode (text starts with !)
93
+ isBashMode = false;
94
+ // Track running bash command process for cancellation
95
+ bashProcess = null;
96
+ // Track current bash execution component
97
+ bashComponent = null;
98
+ constructor(agent, sessionManager, settingsManager, version, changelogMarkdown = null, collapseChangelog = false, scopedModels = [], fdPath = null) {
83
99
  this.agent = agent;
84
100
  this.sessionManager = sessionManager;
85
101
  this.settingsManager = settingsManager;
86
102
  this.version = version;
87
103
  this.changelogMarkdown = changelogMarkdown;
104
+ this.collapseChangelog = collapseChangelog;
88
105
  this.scopedModels = scopedModels;
89
106
  this.ui = new TUI(new ProcessTerminal());
90
107
  this.chatContainer = new Container();
@@ -218,6 +235,9 @@ export class TuiRenderer {
218
235
  theme.fg("dim", "/") +
219
236
  theme.fg("muted", " for commands") +
220
237
  "\n" +
238
+ theme.fg("dim", "!") +
239
+ theme.fg("muted", " to run bash") +
240
+ "\n" +
221
241
  theme.fg("dim", "drop files") +
222
242
  theme.fg("muted", " to attach");
223
243
  const header = new Text(logo + "\n" + instructions, 1, 0);
@@ -228,10 +248,19 @@ export class TuiRenderer {
228
248
  // Add changelog if provided
229
249
  if (this.changelogMarkdown) {
230
250
  this.ui.addChild(new DynamicBorder());
231
- this.ui.addChild(new Text(theme.bold(theme.fg("accent", "What's New")), 1, 0));
232
- this.ui.addChild(new Spacer(1));
233
- this.ui.addChild(new Markdown(this.changelogMarkdown.trim(), 1, 0, getMarkdownTheme()));
234
- this.ui.addChild(new Spacer(1));
251
+ if (this.collapseChangelog) {
252
+ // Show condensed version with hint to use /changelog
253
+ const versionMatch = this.changelogMarkdown.match(/##\s+\[?(\d+\.\d+\.\d+)\]?/);
254
+ const latestVersion = versionMatch ? versionMatch[1] : this.version;
255
+ const condensedText = `Updated to v${latestVersion}. Use ${theme.bold("/changelog")} to view full changelog.`;
256
+ this.ui.addChild(new Text(condensedText, 1, 0));
257
+ }
258
+ else {
259
+ this.ui.addChild(new Text(theme.bold(theme.fg("accent", "What's New")), 1, 0));
260
+ this.ui.addChild(new Spacer(1));
261
+ this.ui.addChild(new Markdown(this.changelogMarkdown.trim(), 1, 0, getMarkdownTheme()));
262
+ this.ui.addChild(new Spacer(1));
263
+ }
235
264
  this.ui.addChild(new DynamicBorder());
236
265
  }
237
266
  this.ui.addChild(this.chatContainer);
@@ -261,6 +290,19 @@ export class TuiRenderer {
261
290
  // Abort
262
291
  this.agent.abort();
263
292
  }
293
+ else if (this.bashProcess) {
294
+ // Kill running bash command
295
+ if (this.bashProcess.pid) {
296
+ killProcessTree(this.bashProcess.pid);
297
+ }
298
+ this.bashProcess = null;
299
+ }
300
+ else if (this.isBashMode) {
301
+ // Cancel bash mode and clear editor
302
+ this.editor.setText("");
303
+ this.isBashMode = false;
304
+ this.updateEditorBorderColor();
305
+ }
264
306
  else if (!this.editor.getText().trim()) {
265
307
  // Double-escape with empty editor triggers /branch
266
308
  const now = Date.now();
@@ -288,6 +330,14 @@ export class TuiRenderer {
288
330
  this.editor.onCtrlT = () => {
289
331
  this.toggleThinkingBlockVisibility();
290
332
  };
333
+ // Handle editor text changes for bash mode detection
334
+ this.editor.onChange = (text) => {
335
+ const wasBashMode = this.isBashMode;
336
+ this.isBashMode = text.trimStart().startsWith("!");
337
+ if (wasBashMode !== this.isBashMode) {
338
+ this.updateEditorBorderColor();
339
+ }
340
+ };
291
341
  // Handle editor submission
292
342
  this.editor.onSubmit = async (text) => {
293
343
  text = text.trim();
@@ -392,6 +442,26 @@ export class TuiRenderer {
392
442
  this.editor.setText("");
393
443
  return;
394
444
  }
445
+ // Check for bash command (!<command>)
446
+ if (text.startsWith("!")) {
447
+ const command = text.slice(1).trim();
448
+ if (command) {
449
+ // Block if bash already running
450
+ if (this.bashProcess) {
451
+ this.showWarning("A bash command is already running. Press Esc to cancel it first.");
452
+ // Restore text since editor clears on submit
453
+ this.editor.setText(text);
454
+ return;
455
+ }
456
+ // Add to history for up/down arrow navigation
457
+ this.editor.addToHistory(text);
458
+ this.handleBashCommand(command);
459
+ // Reset bash mode since editor is now empty
460
+ this.isBashMode = false;
461
+ this.updateEditorBorderColor();
462
+ return;
463
+ }
464
+ }
395
465
  // Check for file-based slash commands
396
466
  text = expandSlashCommand(text, this.fileCommands);
397
467
  // Normal message submission - validate model and API key first
@@ -647,6 +717,17 @@ export class TuiRenderer {
647
717
  }
648
718
  }
649
719
  addMessageToChat(message) {
720
+ // Handle bash execution messages
721
+ if (isBashExecutionMessage(message)) {
722
+ const bashMsg = message;
723
+ const component = new BashExecutionComponent(bashMsg.command, this.ui);
724
+ if (bashMsg.output) {
725
+ component.appendOutput(bashMsg.output);
726
+ }
727
+ component.setComplete(bashMsg.exitCode, bashMsg.cancelled, bashMsg.truncated ? { truncated: true } : undefined, bashMsg.fullOutputPath);
728
+ this.chatContainer.addChild(component);
729
+ return;
730
+ }
650
731
  if (message.role === "user") {
651
732
  const userMsg = message;
652
733
  // Extract text content from content blocks
@@ -681,6 +762,11 @@ export class TuiRenderer {
681
762
  // Render messages
682
763
  for (let i = 0; i < state.messages.length; i++) {
683
764
  const message = state.messages[i];
765
+ // Handle bash execution messages
766
+ if (isBashExecutionMessage(message)) {
767
+ this.addMessageToChat(message);
768
+ continue;
769
+ }
684
770
  if (message.role === "user") {
685
771
  const userMsg = message;
686
772
  const textBlocks = typeof userMsg.content === "string"
@@ -774,6 +860,11 @@ export class TuiRenderer {
774
860
  // Get compaction info if any
775
861
  const compactionEntry = getLatestCompactionEntry(this.sessionManager.loadEntries());
776
862
  for (const message of this.agent.state.messages) {
863
+ // Handle bash execution messages
864
+ if (isBashExecutionMessage(message)) {
865
+ this.addMessageToChat(message);
866
+ continue;
867
+ }
777
868
  if (message.role === "user") {
778
869
  const userMsg = message;
779
870
  const textBlocks = typeof userMsg.content === "string"
@@ -838,8 +929,13 @@ export class TuiRenderer {
838
929
  }
839
930
  }
840
931
  updateEditorBorderColor() {
841
- const level = this.agent.state.thinkingLevel || "off";
842
- this.editor.borderColor = theme.getThinkingBorderColor(level);
932
+ if (this.isBashMode) {
933
+ this.editor.borderColor = theme.getBashModeBorderColor();
934
+ }
935
+ else {
936
+ const level = this.agent.state.thinkingLevel || "off";
937
+ this.editor.borderColor = theme.getThinkingBorderColor(level);
938
+ }
843
939
  this.ui.requestRender();
844
940
  }
845
941
  cycleThinkingLevel() {
@@ -850,7 +946,12 @@ export class TuiRenderer {
850
946
  this.ui.requestRender();
851
947
  return;
852
948
  }
853
- const levels = ["off", "minimal", "low", "medium", "high"];
949
+ // xhigh is only available for codex-max models
950
+ const modelId = this.agent.state.model?.id || "";
951
+ const supportsXhigh = modelId.includes("codex-max");
952
+ const levels = supportsXhigh
953
+ ? ["off", "minimal", "low", "medium", "high", "xhigh"]
954
+ : ["off", "minimal", "low", "medium", "high"];
854
955
  const currentLevel = this.agent.state.thinkingLevel || "off";
855
956
  const currentIndex = levels.indexOf(currentLevel);
856
957
  const nextIndex = (currentIndex + 1) % levels.length;
@@ -954,7 +1055,7 @@ export class TuiRenderer {
954
1055
  }
955
1056
  toggleToolOutputExpansion() {
956
1057
  this.toolOutputExpanded = !this.toolOutputExpanded;
957
- // Update all tool execution and compaction components
1058
+ // Update all tool execution, compaction, and bash execution components
958
1059
  for (const child of this.chatContainer.children) {
959
1060
  if (child instanceof ToolExecutionComponent) {
960
1061
  child.setExpanded(this.toolOutputExpanded);
@@ -962,6 +1063,9 @@ export class TuiRenderer {
962
1063
  else if (child instanceof CompactionComponent) {
963
1064
  child.setExpanded(this.toolOutputExpanded);
964
1065
  }
1066
+ else if (child instanceof BashExecutionComponent) {
1067
+ child.setExpanded(this.toolOutputExpanded);
1068
+ }
965
1069
  }
966
1070
  this.ui.requestRender();
967
1071
  }
@@ -1594,6 +1698,117 @@ export class TuiRenderer {
1594
1698
  this.chatContainer.addChild(new Text(theme.fg("accent", "✓ Debug log written") + "\n" + theme.fg("muted", debugLogPath), 1, 1));
1595
1699
  this.ui.requestRender();
1596
1700
  }
1701
+ async handleBashCommand(command) {
1702
+ // Create component and add to chat
1703
+ this.bashComponent = new BashExecutionComponent(command, this.ui);
1704
+ this.chatContainer.addChild(this.bashComponent);
1705
+ this.ui.requestRender();
1706
+ try {
1707
+ const result = await this.executeBashCommand(command, (chunk) => {
1708
+ if (this.bashComponent) {
1709
+ this.bashComponent.appendOutput(chunk);
1710
+ this.ui.requestRender();
1711
+ }
1712
+ });
1713
+ if (this.bashComponent) {
1714
+ this.bashComponent.setComplete(result.exitCode, result.cancelled, result.truncationResult, result.fullOutputPath);
1715
+ // Create and save message (even if cancelled, for consistency with LLM aborts)
1716
+ const bashMessage = {
1717
+ role: "bashExecution",
1718
+ command,
1719
+ output: result.truncationResult?.content || this.bashComponent.getOutput(),
1720
+ exitCode: result.exitCode,
1721
+ cancelled: result.cancelled,
1722
+ truncated: result.truncationResult?.truncated || false,
1723
+ fullOutputPath: result.fullOutputPath,
1724
+ timestamp: Date.now(),
1725
+ };
1726
+ // Add to agent state
1727
+ this.agent.appendMessage(bashMessage);
1728
+ // Save to session
1729
+ this.sessionManager.saveMessage(bashMessage);
1730
+ }
1731
+ }
1732
+ catch (error) {
1733
+ const errorMessage = error instanceof Error ? error.message : "Unknown error";
1734
+ if (this.bashComponent) {
1735
+ this.bashComponent.setComplete(null, false);
1736
+ }
1737
+ this.showError(`Bash command failed: ${errorMessage}`);
1738
+ }
1739
+ this.bashComponent = null;
1740
+ this.ui.requestRender();
1741
+ }
1742
+ executeBashCommand(command, onChunk) {
1743
+ return new Promise((resolve, reject) => {
1744
+ const { shell, args } = getShellConfig();
1745
+ const child = spawn(shell, [...args, command], {
1746
+ detached: true,
1747
+ stdio: ["ignore", "pipe", "pipe"],
1748
+ });
1749
+ this.bashProcess = child;
1750
+ // Track output for truncation
1751
+ const chunks = [];
1752
+ let chunksBytes = 0;
1753
+ const maxChunksBytes = DEFAULT_MAX_BYTES * 2;
1754
+ // Temp file for large output
1755
+ let tempFilePath;
1756
+ let tempFileStream;
1757
+ let totalBytes = 0;
1758
+ const handleData = (data) => {
1759
+ totalBytes += data.length;
1760
+ // Start writing to temp file if exceeds threshold
1761
+ if (totalBytes > DEFAULT_MAX_BYTES && !tempFilePath) {
1762
+ const id = randomBytes(8).toString("hex");
1763
+ tempFilePath = join(tmpdir(), `pi-bash-${id}.log`);
1764
+ tempFileStream = createWriteStream(tempFilePath);
1765
+ for (const chunk of chunks) {
1766
+ tempFileStream.write(chunk);
1767
+ }
1768
+ }
1769
+ if (tempFileStream) {
1770
+ tempFileStream.write(data);
1771
+ }
1772
+ // Keep rolling buffer
1773
+ chunks.push(data);
1774
+ chunksBytes += data.length;
1775
+ while (chunksBytes > maxChunksBytes && chunks.length > 1) {
1776
+ const removed = chunks.shift();
1777
+ chunksBytes -= removed.length;
1778
+ }
1779
+ // Stream to component (strip ANSI)
1780
+ const text = stripAnsi(data.toString()).replace(/\r/g, "");
1781
+ onChunk(text);
1782
+ };
1783
+ child.stdout?.on("data", handleData);
1784
+ child.stderr?.on("data", handleData);
1785
+ child.on("close", (code) => {
1786
+ if (tempFileStream) {
1787
+ tempFileStream.end();
1788
+ }
1789
+ this.bashProcess = null;
1790
+ // Combine buffered chunks for truncation
1791
+ const fullBuffer = Buffer.concat(chunks);
1792
+ const fullOutput = stripAnsi(fullBuffer.toString("utf-8")).replace(/\r/g, "");
1793
+ const truncationResult = truncateTail(fullOutput);
1794
+ // code === null means killed (cancelled)
1795
+ const cancelled = code === null;
1796
+ resolve({
1797
+ exitCode: code,
1798
+ cancelled,
1799
+ truncationResult: truncationResult.truncated ? truncationResult : undefined,
1800
+ fullOutputPath: tempFilePath,
1801
+ });
1802
+ });
1803
+ child.on("error", (err) => {
1804
+ if (tempFileStream) {
1805
+ tempFileStream.end();
1806
+ }
1807
+ this.bashProcess = null;
1808
+ reject(err);
1809
+ });
1810
+ });
1811
+ }
1597
1812
  compactionAbortController = null;
1598
1813
  /**
1599
1814
  * Shared logic to execute context compaction.