From de4f14bb0cfef5aa07dcc42e858aef08c68b3efa Mon Sep 17 00:00:00 2001 From: Alex Saveau Date: Tue, 28 Jul 2026 08:39:25 -0400 Subject: [PATCH] strip $ prompt prefix from shell snippets on copy to clipboard --- .../code-snippets/SnippetContainer.jsx | 6 +-- .../doc-elements/code-snippets/codeUtils.js | 20 +++++++ .../stripShellPromptPrefix.test.js | 52 +++++++++++++++++++ 3 files changed, 75 insertions(+), 3 deletions(-) create mode 100644 znai-reactjs/src/doc-elements/code-snippets/stripShellPromptPrefix.test.js diff --git a/znai-reactjs/src/doc-elements/code-snippets/SnippetContainer.jsx b/znai-reactjs/src/doc-elements/code-snippets/SnippetContainer.jsx index fb9071510..a4c6389fe 100644 --- a/znai-reactjs/src/doc-elements/code-snippets/SnippetContainer.jsx +++ b/znai-reactjs/src/doc-elements/code-snippets/SnippetContainer.jsx @@ -19,7 +19,7 @@ import * as React from "react"; import ClipboardJS from "clipboard"; -import { extractTextFromTokens } from "./codeUtils"; +import { extractTextFromTokens, stripShellPromptPrefix } from "./codeUtils"; import { Icon } from "../icons/Icon"; import { SnippetOptionallyScrollablePart } from "./SnippetOptionallyScrollablePart"; @@ -129,11 +129,11 @@ class SnippetContainer extends React.Component { this.clipboard = new ClipboardJS(this.copyToClipboardNode, { text: () => { - const { linesOfCode, tokensForClipboardProvider } = this.props; + const { lang, linesOfCode, tokensForClipboardProvider } = this.props; this.setState({ displayCopied: true }); this.startRemoveFeedbackTimer(); - return extractTextFromTokens(tokensToUse()); + return stripShellPromptPrefix(extractTextFromTokens(tokensToUse()), lang); function tokensToUse() { if (tokensForClipboardProvider) { diff --git a/znai-reactjs/src/doc-elements/code-snippets/codeUtils.js b/znai-reactjs/src/doc-elements/code-snippets/codeUtils.js index a330a7d43..b1a6e7b09 100644 --- a/znai-reactjs/src/doc-elements/code-snippets/codeUtils.js +++ b/znai-reactjs/src/doc-elements/code-snippets/codeUtils.js @@ -170,6 +170,26 @@ export function extractTextFromTokens(tokens) { return tokens.map(t => tokenToText(t)).join('') } +const shellLangs = new Set(['bash', 'sh', 'shell', 'shell-session', 'zsh', 'console']) + +/** + * strips the optional "$ " prompt prefix from each line of a shell snippet, + * so that copied commands can be pasted into a terminal as-is + * @param text snippet text + * @param lang snippet language + * @return {String} + */ +export function stripShellPromptPrefix(text, lang) { + if (!lang || !shellLangs.has(lang.toLowerCase())) { + return text + } + + return text + .split('\n') + .map(line => line.replace(/^(\s*)\$ /, '$1')) + .join('\n') +} + function tokenToText(token) { if (typeof token === 'string') { return token diff --git a/znai-reactjs/src/doc-elements/code-snippets/stripShellPromptPrefix.test.js b/znai-reactjs/src/doc-elements/code-snippets/stripShellPromptPrefix.test.js new file mode 100644 index 000000000..c1960c67f --- /dev/null +++ b/znai-reactjs/src/doc-elements/code-snippets/stripShellPromptPrefix.test.js @@ -0,0 +1,52 @@ +/* + * Copyright 2026 znai maintainers + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { describe, it, expect } from 'vitest'; + +import { stripShellPromptPrefix } from "./codeUtils"; + +describe("stripShellPromptPrefix", () => { + it("removes the $ prompt prefix from bash snippet lines", () => { + const text = '$ echo hello\n$ ls -la' + expect(stripShellPromptPrefix(text, 'bash')).toEqual('echo hello\nls -la') + }) + + it("keeps lines without a prompt untouched, e.g. line continuations and output", () => { + const text = '$ my-tool run \\\n' + + ' -flag value\n' + + 'output line\n' + + '\n' + + '$ another-command' + expect(stripShellPromptPrefix(text, 'sh')).toEqual( + 'my-tool run \\\n' + + ' -flag value\n' + + 'output line\n' + + '\n' + + 'another-command') + }) + + it("preserves indentation before the prompt", () => { + expect(stripShellPromptPrefix(' $ echo indented', 'bash')).toEqual(' echo indented') + }) + + it("does not remove $ that is part of a variable reference", () => { + expect(stripShellPromptPrefix('$HOME/bin/tool', 'bash')).toEqual('$HOME/bin/tool') + }) + + it("does not change snippets of other languages", () => { + expect(stripShellPromptPrefix('$ looks like prompt', 'java')).toEqual('$ looks like prompt') + expect(stripShellPromptPrefix('$ looks like prompt', undefined)).toEqual('$ looks like prompt') + }) +})