@mariozechner/pi-coding-agent 0.32.3 → 0.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/README.md +76 -2
- package/dist/core/export-html/template.css +34 -4
- package/dist/core/export-html/template.js +17 -4
- package/dist/core/keybindings.d.ts +59 -0
- package/dist/core/keybindings.d.ts.map +1 -0
- package/dist/core/keybindings.js +149 -0
- package/dist/core/keybindings.js.map +1 -0
- package/dist/modes/interactive/components/custom-editor.d.ts +11 -12
- package/dist/modes/interactive/components/custom-editor.d.ts.map +1 -1
- package/dist/modes/interactive/components/custom-editor.js +48 -72
- package/dist/modes/interactive/components/custom-editor.js.map +1 -1
- package/dist/modes/interactive/components/hook-editor.d.ts.map +1 -1
- package/dist/modes/interactive/components/hook-editor.js +5 -4
- package/dist/modes/interactive/components/hook-editor.js.map +1 -1
- package/dist/modes/interactive/components/hook-input.d.ts.map +1 -1
- package/dist/modes/interactive/components/hook-input.js +4 -3
- package/dist/modes/interactive/components/hook-input.js.map +1 -1
- package/dist/modes/interactive/components/hook-selector.d.ts.map +1 -1
- package/dist/modes/interactive/components/hook-selector.js +6 -5
- package/dist/modes/interactive/components/hook-selector.js.map +1 -1
- package/dist/modes/interactive/components/model-selector.d.ts.map +1 -1
- package/dist/modes/interactive/components/model-selector.js +6 -5
- package/dist/modes/interactive/components/model-selector.js.map +1 -1
- package/dist/modes/interactive/components/oauth-selector.d.ts.map +1 -1
- package/dist/modes/interactive/components/oauth-selector.js +6 -5
- package/dist/modes/interactive/components/oauth-selector.js.map +1 -1
- package/dist/modes/interactive/components/session-selector.d.ts.map +1 -1
- package/dist/modes/interactive/components/session-selector.js +6 -9
- package/dist/modes/interactive/components/session-selector.js.map +1 -1
- package/dist/modes/interactive/components/tree-selector.d.ts.map +1 -1
- package/dist/modes/interactive/components/tree-selector.js +14 -15
- package/dist/modes/interactive/components/tree-selector.js.map +1 -1
- package/dist/modes/interactive/components/user-message-selector.d.ts.map +1 -1
- package/dist/modes/interactive/components/user-message-selector.js +6 -11
- package/dist/modes/interactive/components/user-message-selector.js.map +1 -1
- package/dist/modes/interactive/interactive-mode.d.ts +21 -1
- package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
- package/dist/modes/interactive/interactive-mode.js +175 -45
- package/dist/modes/interactive/interactive-mode.js.map +1 -1
- package/docs/tui.md +18 -15
- package/examples/custom-tools/subagent/README.md +2 -2
- package/examples/hooks/snake.ts +7 -7
- package/examples/hooks/todo/index.ts +2 -2
- package/package.json +5 -4
|
@@ -2,12 +2,15 @@
|
|
|
2
2
|
* Interactive mode for the coding agent.
|
|
3
3
|
* Handles TUI rendering and user interaction, delegating business logic to AgentSession.
|
|
4
4
|
*/
|
|
5
|
+
import * as crypto from "node:crypto";
|
|
5
6
|
import * as fs from "node:fs";
|
|
6
7
|
import * as os from "node:os";
|
|
7
8
|
import * as path from "node:path";
|
|
8
|
-
import
|
|
9
|
+
import Clipboard from "@crosscopy/clipboard";
|
|
10
|
+
import { CombinedAutocompleteProvider, Container, getEditorKeybindings, Input, Loader, Markdown, ProcessTerminal, Spacer, Text, TruncatedText, TUI, visibleWidth, } from "@mariozechner/pi-tui";
|
|
9
11
|
import { exec, spawn, spawnSync } from "child_process";
|
|
10
12
|
import { APP_NAME, getAuthPath, getDebugLogPath } from "../../config.js";
|
|
13
|
+
import { KeybindingsManager } from "../../core/keybindings.js";
|
|
11
14
|
import { createCompactionSummaryMessage } from "../../core/messages.js";
|
|
12
15
|
import { SessionManager } from "../../core/session-manager.js";
|
|
13
16
|
import { loadSkills } from "../../core/skills.js";
|
|
@@ -49,6 +52,7 @@ export class InteractiveMode {
|
|
|
49
52
|
editor;
|
|
50
53
|
editorContainer;
|
|
51
54
|
footer;
|
|
55
|
+
keybindings;
|
|
52
56
|
version;
|
|
53
57
|
isInitialized = false;
|
|
54
58
|
onInputCallback;
|
|
@@ -88,6 +92,9 @@ export class InteractiveMode {
|
|
|
88
92
|
hookEditor = undefined;
|
|
89
93
|
// Custom tools for custom rendering
|
|
90
94
|
customTools;
|
|
95
|
+
// Clipboard image tracking: imageId -> temp file path
|
|
96
|
+
clipboardImages = new Map();
|
|
97
|
+
clipboardImageCounter = 0;
|
|
91
98
|
// Convenience accessors
|
|
92
99
|
get agent() {
|
|
93
100
|
return this.session.agent;
|
|
@@ -108,7 +115,8 @@ export class InteractiveMode {
|
|
|
108
115
|
this.chatContainer = new Container();
|
|
109
116
|
this.pendingMessagesContainer = new Container();
|
|
110
117
|
this.statusContainer = new Container();
|
|
111
|
-
this.
|
|
118
|
+
this.keybindings = KeybindingsManager.create();
|
|
119
|
+
this.editor = new CustomEditor(getEditorTheme(), this.keybindings);
|
|
112
120
|
this.editorContainer = new Container();
|
|
113
121
|
this.editorContainer.addChild(this.editor);
|
|
114
122
|
this.footer = new FooterComponent(session);
|
|
@@ -150,42 +158,61 @@ export class InteractiveMode {
|
|
|
150
158
|
async init() {
|
|
151
159
|
if (this.isInitialized)
|
|
152
160
|
return;
|
|
153
|
-
// Add header
|
|
161
|
+
// Add header with keybindings from config
|
|
154
162
|
const logo = theme.bold(theme.fg("accent", APP_NAME)) + theme.fg("dim", ` v${this.version}`);
|
|
155
|
-
|
|
163
|
+
// Format keybinding for startup display (lowercase, compact)
|
|
164
|
+
const formatStartupKey = (keys) => {
|
|
165
|
+
const keyArray = Array.isArray(keys) ? keys : [keys];
|
|
166
|
+
return keyArray.join("/");
|
|
167
|
+
};
|
|
168
|
+
const kb = this.keybindings;
|
|
169
|
+
const interrupt = formatStartupKey(kb.getKeys("interrupt"));
|
|
170
|
+
const clear = formatStartupKey(kb.getKeys("clear"));
|
|
171
|
+
const exit = formatStartupKey(kb.getKeys("exit"));
|
|
172
|
+
const suspend = formatStartupKey(kb.getKeys("suspend"));
|
|
173
|
+
const deleteToLineEnd = formatStartupKey(getEditorKeybindings().getKeys("deleteToLineEnd"));
|
|
174
|
+
const cycleThinkingLevel = formatStartupKey(kb.getKeys("cycleThinkingLevel"));
|
|
175
|
+
const cycleModelForward = formatStartupKey(kb.getKeys("cycleModelForward"));
|
|
176
|
+
const cycleModelBackward = formatStartupKey(kb.getKeys("cycleModelBackward"));
|
|
177
|
+
const selectModel = formatStartupKey(kb.getKeys("selectModel"));
|
|
178
|
+
const expandTools = formatStartupKey(kb.getKeys("expandTools"));
|
|
179
|
+
const toggleThinking = formatStartupKey(kb.getKeys("toggleThinking"));
|
|
180
|
+
const externalEditor = formatStartupKey(kb.getKeys("externalEditor"));
|
|
181
|
+
const followUp = formatStartupKey(kb.getKeys("followUp"));
|
|
182
|
+
const instructions = theme.fg("dim", interrupt) +
|
|
156
183
|
theme.fg("muted", " to interrupt") +
|
|
157
184
|
"\n" +
|
|
158
|
-
theme.fg("dim",
|
|
185
|
+
theme.fg("dim", clear) +
|
|
159
186
|
theme.fg("muted", " to clear") +
|
|
160
187
|
"\n" +
|
|
161
|
-
theme.fg("dim",
|
|
188
|
+
theme.fg("dim", `${clear} twice`) +
|
|
162
189
|
theme.fg("muted", " to exit") +
|
|
163
190
|
"\n" +
|
|
164
|
-
theme.fg("dim",
|
|
191
|
+
theme.fg("dim", exit) +
|
|
165
192
|
theme.fg("muted", " to exit (empty)") +
|
|
166
193
|
"\n" +
|
|
167
|
-
theme.fg("dim",
|
|
194
|
+
theme.fg("dim", suspend) +
|
|
168
195
|
theme.fg("muted", " to suspend") +
|
|
169
196
|
"\n" +
|
|
170
|
-
theme.fg("dim",
|
|
197
|
+
theme.fg("dim", deleteToLineEnd) +
|
|
171
198
|
theme.fg("muted", " to delete line") +
|
|
172
199
|
"\n" +
|
|
173
|
-
theme.fg("dim",
|
|
200
|
+
theme.fg("dim", cycleThinkingLevel) +
|
|
174
201
|
theme.fg("muted", " to cycle thinking") +
|
|
175
202
|
"\n" +
|
|
176
|
-
theme.fg("dim",
|
|
203
|
+
theme.fg("dim", `${cycleModelForward}/${cycleModelBackward}`) +
|
|
177
204
|
theme.fg("muted", " to cycle models") +
|
|
178
205
|
"\n" +
|
|
179
|
-
theme.fg("dim",
|
|
206
|
+
theme.fg("dim", selectModel) +
|
|
180
207
|
theme.fg("muted", " to select model") +
|
|
181
208
|
"\n" +
|
|
182
|
-
theme.fg("dim",
|
|
209
|
+
theme.fg("dim", expandTools) +
|
|
183
210
|
theme.fg("muted", " to expand tools") +
|
|
184
211
|
"\n" +
|
|
185
|
-
theme.fg("dim",
|
|
212
|
+
theme.fg("dim", toggleThinking) +
|
|
186
213
|
theme.fg("muted", " to toggle thinking") +
|
|
187
214
|
"\n" +
|
|
188
|
-
theme.fg("dim",
|
|
215
|
+
theme.fg("dim", externalEditor) +
|
|
189
216
|
theme.fg("muted", " for external editor") +
|
|
190
217
|
"\n" +
|
|
191
218
|
theme.fg("dim", "/") +
|
|
@@ -194,9 +221,12 @@ export class InteractiveMode {
|
|
|
194
221
|
theme.fg("dim", "!") +
|
|
195
222
|
theme.fg("muted", " to run bash") +
|
|
196
223
|
"\n" +
|
|
197
|
-
theme.fg("dim",
|
|
224
|
+
theme.fg("dim", followUp) +
|
|
198
225
|
theme.fg("muted", " to queue follow-up") +
|
|
199
226
|
"\n" +
|
|
227
|
+
theme.fg("dim", "ctrl+v") +
|
|
228
|
+
theme.fg("muted", " to paste image") +
|
|
229
|
+
"\n" +
|
|
200
230
|
theme.fg("dim", "drop files") +
|
|
201
231
|
theme.fg("muted", " to attach");
|
|
202
232
|
const header = new Text(`${logo}\n${instructions}`, 1, 0);
|
|
@@ -636,19 +666,20 @@ export class InteractiveMode {
|
|
|
636
666
|
}
|
|
637
667
|
}
|
|
638
668
|
};
|
|
639
|
-
|
|
669
|
+
// Register app action handlers
|
|
670
|
+
this.editor.onAction("clear", () => this.handleCtrlC());
|
|
640
671
|
this.editor.onCtrlD = () => this.handleCtrlD();
|
|
641
|
-
this.editor.
|
|
642
|
-
this.editor.
|
|
643
|
-
this.editor.
|
|
644
|
-
this.editor.
|
|
672
|
+
this.editor.onAction("suspend", () => this.handleCtrlZ());
|
|
673
|
+
this.editor.onAction("cycleThinkingLevel", () => this.cycleThinkingLevel());
|
|
674
|
+
this.editor.onAction("cycleModelForward", () => this.cycleModel("forward"));
|
|
675
|
+
this.editor.onAction("cycleModelBackward", () => this.cycleModel("backward"));
|
|
645
676
|
// Global debug handler on TUI (works regardless of focus)
|
|
646
677
|
this.ui.onDebug = () => this.handleDebugCommand();
|
|
647
|
-
this.editor.
|
|
648
|
-
this.editor.
|
|
649
|
-
this.editor.
|
|
650
|
-
this.editor.
|
|
651
|
-
this.editor.
|
|
678
|
+
this.editor.onAction("selectModel", () => this.showModelSelector());
|
|
679
|
+
this.editor.onAction("expandTools", () => this.toggleToolOutputExpansion());
|
|
680
|
+
this.editor.onAction("toggleThinking", () => this.toggleThinkingBlockVisibility());
|
|
681
|
+
this.editor.onAction("externalEditor", () => this.openExternalEditor());
|
|
682
|
+
this.editor.onAction("followUp", () => this.handleFollowUp());
|
|
652
683
|
this.editor.onChange = (text) => {
|
|
653
684
|
const wasBashMode = this.isBashMode;
|
|
654
685
|
this.isBashMode = text.trimStart().startsWith("!");
|
|
@@ -656,6 +687,47 @@ export class InteractiveMode {
|
|
|
656
687
|
this.updateEditorBorderColor();
|
|
657
688
|
}
|
|
658
689
|
};
|
|
690
|
+
// Handle clipboard image paste (triggered on Ctrl+V)
|
|
691
|
+
this.editor.onPasteImage = () => {
|
|
692
|
+
this.handleClipboardImagePaste();
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
async handleClipboardImagePaste() {
|
|
696
|
+
try {
|
|
697
|
+
if (!Clipboard.hasImage()) {
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
const imageData = await Clipboard.getImageBinary();
|
|
701
|
+
if (!imageData || imageData.length === 0) {
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
// Write to temp file
|
|
705
|
+
const imageId = ++this.clipboardImageCounter;
|
|
706
|
+
const tmpDir = os.tmpdir();
|
|
707
|
+
const fileName = `pi-clipboard-${crypto.randomUUID()}.png`;
|
|
708
|
+
const filePath = path.join(tmpDir, fileName);
|
|
709
|
+
fs.writeFileSync(filePath, Buffer.from(imageData));
|
|
710
|
+
// Store mapping and insert marker
|
|
711
|
+
this.clipboardImages.set(imageId, filePath);
|
|
712
|
+
this.editor.insertTextAtCursor(`[image #${imageId}]`);
|
|
713
|
+
this.ui.requestRender();
|
|
714
|
+
}
|
|
715
|
+
catch {
|
|
716
|
+
// Silently ignore clipboard errors (may not have permission, etc.)
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
720
|
+
* Replace [image #N] markers with actual file paths and clear the image map.
|
|
721
|
+
*/
|
|
722
|
+
replaceImageMarkers(text) {
|
|
723
|
+
let result = text;
|
|
724
|
+
for (const [imageId, filePath] of this.clipboardImages) {
|
|
725
|
+
const marker = `[image #${imageId}]`;
|
|
726
|
+
result = result.replace(marker, filePath);
|
|
727
|
+
}
|
|
728
|
+
this.clipboardImages.clear();
|
|
729
|
+
this.clipboardImageCounter = 0;
|
|
730
|
+
return result;
|
|
659
731
|
}
|
|
660
732
|
setupEditorSubmitHandler() {
|
|
661
733
|
this.editor.onSubmit = async (text) => {
|
|
@@ -755,6 +827,11 @@ export class InteractiveMode {
|
|
|
755
827
|
this.editor.setText("");
|
|
756
828
|
return;
|
|
757
829
|
}
|
|
830
|
+
if (text === "/quit" || text === "/exit") {
|
|
831
|
+
this.editor.setText("");
|
|
832
|
+
await this.shutdown();
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
758
835
|
// Handle bash command (! for normal, !! for excluded from context)
|
|
759
836
|
if (text.startsWith("!")) {
|
|
760
837
|
const isExcluded = text.startsWith("!!");
|
|
@@ -777,6 +854,8 @@ export class InteractiveMode {
|
|
|
777
854
|
return;
|
|
778
855
|
}
|
|
779
856
|
// If streaming, use prompt() with steer behavior
|
|
857
|
+
// Replace image markers with actual file paths
|
|
858
|
+
text = this.replaceImageMarkers(text);
|
|
780
859
|
// This handles hook commands (execute immediately), slash command expansion, and queueing
|
|
781
860
|
if (this.session.isStreaming) {
|
|
782
861
|
this.editor.addToHistory(text);
|
|
@@ -1228,7 +1307,7 @@ export class InteractiveMode {
|
|
|
1228
1307
|
// Send SIGTSTP to process group (pid=0 means all processes in group)
|
|
1229
1308
|
process.kill(0, "SIGTSTP");
|
|
1230
1309
|
}
|
|
1231
|
-
async
|
|
1310
|
+
async handleFollowUp() {
|
|
1232
1311
|
const text = this.editor.getText().trim();
|
|
1233
1312
|
if (!text)
|
|
1234
1313
|
return;
|
|
@@ -1920,38 +1999,89 @@ export class InteractiveMode {
|
|
|
1920
1999
|
this.chatContainer.addChild(new DynamicBorder());
|
|
1921
2000
|
this.ui.requestRender();
|
|
1922
2001
|
}
|
|
2002
|
+
/**
|
|
2003
|
+
* Format keybindings for display (e.g., "ctrl+c" -> "Ctrl+C").
|
|
2004
|
+
*/
|
|
2005
|
+
formatKeyDisplay(keys) {
|
|
2006
|
+
const keyArray = Array.isArray(keys) ? keys : [keys];
|
|
2007
|
+
return keyArray
|
|
2008
|
+
.map((key) => key
|
|
2009
|
+
.split("+")
|
|
2010
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
2011
|
+
.join("+"))
|
|
2012
|
+
.join("/");
|
|
2013
|
+
}
|
|
2014
|
+
/**
|
|
2015
|
+
* Get display string for an app keybinding action.
|
|
2016
|
+
*/
|
|
2017
|
+
getAppKeyDisplay(action) {
|
|
2018
|
+
const display = this.keybindings.getDisplayString(action);
|
|
2019
|
+
return this.formatKeyDisplay(display);
|
|
2020
|
+
}
|
|
2021
|
+
/**
|
|
2022
|
+
* Get display string for an editor keybinding action.
|
|
2023
|
+
*/
|
|
2024
|
+
getEditorKeyDisplay(action) {
|
|
2025
|
+
const keys = getEditorKeybindings().getKeys(action);
|
|
2026
|
+
return this.formatKeyDisplay(keys);
|
|
2027
|
+
}
|
|
1923
2028
|
handleHotkeysCommand() {
|
|
2029
|
+
// Navigation keybindings
|
|
2030
|
+
const cursorWordLeft = this.getEditorKeyDisplay("cursorWordLeft");
|
|
2031
|
+
const cursorWordRight = this.getEditorKeyDisplay("cursorWordRight");
|
|
2032
|
+
const cursorLineStart = this.getEditorKeyDisplay("cursorLineStart");
|
|
2033
|
+
const cursorLineEnd = this.getEditorKeyDisplay("cursorLineEnd");
|
|
2034
|
+
// Editing keybindings
|
|
2035
|
+
const submit = this.getEditorKeyDisplay("submit");
|
|
2036
|
+
const newLine = this.getEditorKeyDisplay("newLine");
|
|
2037
|
+
const deleteWordBackward = this.getEditorKeyDisplay("deleteWordBackward");
|
|
2038
|
+
const deleteToLineStart = this.getEditorKeyDisplay("deleteToLineStart");
|
|
2039
|
+
const deleteToLineEnd = this.getEditorKeyDisplay("deleteToLineEnd");
|
|
2040
|
+
const tab = this.getEditorKeyDisplay("tab");
|
|
2041
|
+
// App keybindings
|
|
2042
|
+
const interrupt = this.getAppKeyDisplay("interrupt");
|
|
2043
|
+
const clear = this.getAppKeyDisplay("clear");
|
|
2044
|
+
const exit = this.getAppKeyDisplay("exit");
|
|
2045
|
+
const suspend = this.getAppKeyDisplay("suspend");
|
|
2046
|
+
const cycleThinkingLevel = this.getAppKeyDisplay("cycleThinkingLevel");
|
|
2047
|
+
const cycleModelForward = this.getAppKeyDisplay("cycleModelForward");
|
|
2048
|
+
const expandTools = this.getAppKeyDisplay("expandTools");
|
|
2049
|
+
const toggleThinking = this.getAppKeyDisplay("toggleThinking");
|
|
2050
|
+
const externalEditor = this.getAppKeyDisplay("externalEditor");
|
|
2051
|
+
const followUp = this.getAppKeyDisplay("followUp");
|
|
1924
2052
|
const hotkeys = `
|
|
1925
2053
|
**Navigation**
|
|
1926
2054
|
| Key | Action |
|
|
1927
2055
|
|-----|--------|
|
|
1928
2056
|
| \`Arrow keys\` | Move cursor / browse history (Up when empty) |
|
|
1929
|
-
| \`
|
|
1930
|
-
| \`
|
|
1931
|
-
| \`
|
|
2057
|
+
| \`${cursorWordLeft}\` / \`${cursorWordRight}\` | Move by word |
|
|
2058
|
+
| \`${cursorLineStart}\` | Start of line |
|
|
2059
|
+
| \`${cursorLineEnd}\` | End of line |
|
|
1932
2060
|
|
|
1933
2061
|
**Editing**
|
|
1934
2062
|
| Key | Action |
|
|
1935
2063
|
|-----|--------|
|
|
1936
|
-
| \`
|
|
1937
|
-
| \`
|
|
1938
|
-
| \`
|
|
1939
|
-
| \`
|
|
1940
|
-
| \`
|
|
2064
|
+
| \`${submit}\` | Send message |
|
|
2065
|
+
| \`${newLine}\` | New line |
|
|
2066
|
+
| \`${deleteWordBackward}\` | Delete word backwards |
|
|
2067
|
+
| \`${deleteToLineStart}\` | Delete to start of line |
|
|
2068
|
+
| \`${deleteToLineEnd}\` | Delete to end of line |
|
|
1941
2069
|
|
|
1942
2070
|
**Other**
|
|
1943
2071
|
| Key | Action |
|
|
1944
2072
|
|-----|--------|
|
|
1945
|
-
| \`
|
|
1946
|
-
| \`
|
|
1947
|
-
| \`
|
|
1948
|
-
| \`
|
|
1949
|
-
| \`
|
|
1950
|
-
| \`
|
|
1951
|
-
| \`
|
|
1952
|
-
| \`
|
|
1953
|
-
| \`
|
|
1954
|
-
| \`
|
|
2073
|
+
| \`${tab}\` | Path completion / accept autocomplete |
|
|
2074
|
+
| \`${interrupt}\` | Cancel autocomplete / abort streaming |
|
|
2075
|
+
| \`${clear}\` | Clear editor (first) / exit (second) |
|
|
2076
|
+
| \`${exit}\` | Exit (when editor is empty) |
|
|
2077
|
+
| \`${suspend}\` | Suspend to background |
|
|
2078
|
+
| \`${cycleThinkingLevel}\` | Cycle thinking level |
|
|
2079
|
+
| \`${cycleModelForward}\` | Cycle models |
|
|
2080
|
+
| \`${expandTools}\` | Toggle tool output expansion |
|
|
2081
|
+
| \`${toggleThinking}\` | Toggle thinking block visibility |
|
|
2082
|
+
| \`${externalEditor}\` | Edit message in external editor |
|
|
2083
|
+
| \`${followUp}\` | Queue follow-up message |
|
|
2084
|
+
| \`Ctrl+V\` | Paste image from clipboard |
|
|
1955
2085
|
| \`/\` | Slash commands |
|
|
1956
2086
|
| \`!\` | Run bash command |
|
|
1957
2087
|
`;
|