Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
---
id: TASK-180
title: Add prompt composer to terminal context menu
status: In Progress
assignee:
- '@mpmisha'
created_date: '2026-06-14 06:39'
updated_date: '2026-06-14 06:54'
labels:
- feature
- frontend
- ux
dependencies: []
priority: medium
---

## Description

<!-- SECTION:DESCRIPTION:BEGIN -->
Add a new option to each terminal's context (pane) menu that opens a lightweight notepad-style text editor, similar in style/placement to the existing transcript/prompts and SessionSummary popovers.

Motivation: composing multi-line prompts directly in the terminal is awkward - newlines/paste are fiddly and a stray Enter submits a half-written message. A scratch composer lets the user write/edit freely, then copy the whole thing to paste into the terminal as a single prompt.

Scope (v1):
- New context menu item in the pane menu (TerminalPanel right-click menu) labeled something like "๐Ÿ“ Prompt composer".
- Opens a modal dialog (palette-backdrop pattern, like SessionSummary) with a multi-line <textarea>, a Copy button, and a Close (โœ•) button.
- Textarea preserves newlines, paste, undo/redo (native browser behavior).
- Copy button writes the full textarea contents to the clipboard via window.terminalAPI.clipboardWrite and shows brief "Copied!" feedback.
- Esc closes; clicking the backdrop closes; clicking inside the card does not close.
- Per-terminal text persists in the store for the session (so closing & reopening keeps draft); cleared when the terminal is closed.

Out of scope (v1):
- Submit-directly-to-terminal button (deferred - user is still deciding).
- Rich text / markdown rendering.
- Persisting drafts across app restarts.
<!-- SECTION:DESCRIPTION:END -->

## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 New ๐Ÿ“ Prompt composer item appears in the terminal pane context menu
- [x] #2 Clicking it opens a modal with a multi-line textarea, Copy button, and Close button
- [x] #3 Textarea accepts newlines, paste, and arbitrary length text
- [x] #4 Copy button puts the full textarea contents on the system clipboard and shows transient 'Copied!' feedback
- [x] #5 Esc and backdrop click close the dialog; clicks inside the card don't
- [x] #6 Reopening the composer for the same terminal restores the previously typed draft within the session
- [x] #7 Draft is cleared when the terminal pane is closed
- [x] #8 Bottom action bar contains three buttons: Copy, Submit, Close
- [x] #9 Submit button writes the textarea contents into the focused terminal using bracketed paste, then closes the dialog
<!-- AC:END -->

## Implementation Plan

<!-- SECTION:PLAN:BEGIN -->
1. Add prompt composer state to terminal store: promptComposerRequest (terminalId|null), composerDrafts map by terminalId, plus actions openPromptComposer, closePromptComposer, setPromptComposerDraft.
2. Clear composerDrafts[terminalId] in the terminal-removal path (closeTerminal or wherever active terminals are removed).
3. Create PromptComposer.tsx component: modal using palette-backdrop pattern (mirrors SessionSummary structure), textarea, Copy button with "Copied!" feedback, Close button, Esc + backdrop close.
4. Add CSS for .prompt-composer-card / .prompt-composer-textarea etc. in global.css (mirror session-summary-* tokens).
5. Mount <PromptComposer /> in App.tsx near <SessionSummary />.
6. Add ๐Ÿ“ Prompt composer item to the TerminalPanel pane context menu, near Show prompts / Session summary.
7. Verify with npm run lint and npm test (or whatever the repo uses). Manually smoke-test in the dev build.
<!-- SECTION:PLAN:END -->

## Implementation Notes

<!-- SECTION:NOTES:BEGIN -->
Plan confirmed with user (2026-06-14):
- Three buttons at the bottom of the editor: Copy, Submit, Close.
- Submit uses bracketed paste (\x1b[200~...\x1b[201~) via window.terminalAPI.writePty, same pattern as DiffReview.sendComments.
- Submit does NOT auto-press Enter (user wants to review in the terminal before sending).

Implementation done on branch users/mimer/feature/prompt-composer.
- Added promptComposerRequest + composerDrafts state and openPromptComposer/closePromptComposer/setPromptComposerDraft actions to terminal-store.ts.
- closeTerminal now drops the per-pane draft and clears the composer request if it was targeting the closed pane.
- New PromptComposer.tsx renders the modal (textarea + Copy/Submit/Close footer), wired with Esc-to-close and click-backdrop-to-close.
- Submit uses bracketed paste via writePty without a trailing \r (user reviews before pressing Enter).
- CSS added under "Prompt Composer" section in global.css.
- Menu item added in TerminalPanel pane context menu, immediately after "Show prompts".
- Verified: tsc --noEmit error count went from 36 (main) to 32 on branch; vite renderer build succeeds.
<!-- SECTION:NOTES:END -->

## Final Summary

<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Adds a notepad-style prompt composer to each terminal pane, accessible from the pane right-click menu (๐Ÿ“ Prompt composer).

Why:
Composing multi-line prompts directly in the terminal is awkward - newlines and paste are fiddly, and a stray Enter submits a half-written message. The composer is a plain modal textarea so the user can write/edit freely, then copy or paste the whole thing into the terminal as a single block.

What changed:
- New PromptComposer.tsx modal (palette-backdrop pattern, mirrors SessionSummary). Bottom footer holds three buttons: Copy, Submit, Close.
- Copy: writes the textarea contents to the clipboard via window.terminalAPI.clipboardWrite, with transient "โœ“ Copied" feedback.
- Submit: pastes into the focused terminal using bracketed paste (\x1b[200~...\x1b[201~) - no trailing \r, so the user reviews before pressing Enter. Same pattern as DiffReview.sendComments.
- Close / Esc / backdrop click: dismiss without sending.
- Terminal store gets promptComposerRequest (TerminalId | null) and composerDrafts (per-pane drafts) with openPromptComposer / closePromptComposer / setPromptComposerDraft.
- closeTerminal drops the draft and clears the composer request when the targeted pane goes away, so drafts never outlive their terminal.
- TerminalPanel pane context menu gets a new "๐Ÿ“ Prompt composer" item placed right after "Show prompts".
- Mounted in App.tsx alongside <SessionSummary />.
- CSS in global.css under a new "Prompt Composer" section, styled to match the session summary card family.

Scope notes:
- Drafts persist only for the current app session (no disk persistence by design).
- No keyboard shortcut yet; opens via the context menu only.

Validation:
- npx tsc --noEmit: no new errors introduced (count went 36 -> 32 vs. main; remaining errors are pre-existing and unrelated).
- npx vite build --config vite.renderer.config.ts: succeeds.
<!-- SECTION:FINAL_SUMMARY:END -->
2 changes: 2 additions & 0 deletions src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import FileExplorer from './components/FileExplorer';
import FloatingRenameInput from './components/FloatingRenameInput';
import Toast from './components/Toast';
import SessionSummary from './components/SessionSummary';
import PromptComposer from './components/PromptComposer';
import MarkdownPreviewOverlay from './components/MarkdownPreviewOverlay';
import AppDialogHost from './components/AppDialog';

Expand Down Expand Up @@ -335,6 +336,7 @@ const App: React.FC = () => {
<DiffReview />
<FloatingRenameInput />
<SessionSummary />
<PromptComposer />
<MarkdownPreviewOverlay />
<Toast />
<AppDialogHost />
Expand Down
17 changes: 16 additions & 1 deletion src/renderer/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ interface Command {

const CommandPalette: React.FC = () => {
const show = useTerminalStore((s) => s.showCommandPalette);
// Subscribe to the focused pane's aiSessionId so commands that only make
// sense for an AI session (Prompt Composer, etc.) can be filtered out
// when the user has a plain shell focused.
const focusedAiSessionId = useTerminalStore((s) => {
const id = s.focusedTerminalId;
return id ? s.terminals.get(id)?.aiSessionId ?? null : null;
});
const [query, setQuery] = useState('');
const [selectedIndex, setSelectedIndex] = useState(0);
const [dialog, setDialog] = useState<{ title: string; placeholder?: string; options?: string[]; onSubmit: (value: string) => void } | null>(null);
Expand Down Expand Up @@ -89,6 +96,14 @@ const CommandPalette: React.FC = () => {
}
}},
{ id: 'jumpToPrompt', label: 'Jump to Prompt', shortcut: 'Ctrl+Shift+K', action: () => { const id = focusedId(); if (id) store().showPromptsForTerminal(id); } },
// Prompt composer is AI-session-only (mirrors the per-pane context
// menu). Hide the entry when the focused pane isn't an AI session.
...(focusedAiSessionId ? [{
id: 'promptComposer',
label: 'Open Prompt Composer',
shortcut: 'Ctrl+Alt+P',
action: () => { const id = focusedId(); if (id) store().openPromptComposer(id); },
}] : []),
{ id: 'searchPrompts', label: 'Search Prompts Across All Panes', shortcut: 'Ctrl+Shift+Y', action: () => store().togglePromptSearch() },
{ id: 'shortcuts', label: 'Show Keyboard Shortcuts', shortcut: 'Ctrl+Shift+?', action: () => store().toggleShortcuts() },
{ id: 'settings', label: 'Open Settings', shortcut: 'Ctrl+,', action: () => store().toggleSettings() },
Expand Down Expand Up @@ -202,7 +217,7 @@ const CommandPalette: React.FC = () => {
action: () => store().createTerminal(shell.id),
})),
];
}, []);
}, [focusedAiSessionId]);

const filtered = useMemo(() => {
const tokens = tokenizeAnd(query);
Expand Down
160 changes: 160 additions & 0 deletions src/renderer/components/PromptComposer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useTerminalStore } from '../state/terminal-store';
import { prepareClipboardPaste } from '../utils/paste';

/**
* Notepad-style scratchpad for composing long, multi-line prompts before
* pasting them into the terminal. Opened from the per-pane context menu
* (TerminalPanel โ†’ "๐Ÿ“ Prompt composer").
*
* Why this exists: editing multi-line text directly in a terminal is
* awkward - newlines and paste are fiddly, and a stray Enter submits a
* half-written message. The composer is a plain <textarea> with three
* actions in the footer:
* - Copy: write the draft to the system clipboard.
* - Submit: bracketed-paste the draft into the focused terminal. We do
* NOT also press Enter - the user reviews in the terminal and
* hits Enter themselves. This is intentional: it avoids
* accidental submission of half-thought-out prompts, and
* Ink-based AI CLIs (Copilot CLI, Claude Code) handle a
* programmatic \r after bracketed paste inconsistently.
* - Close: dismiss without sending (also bound to Esc and backdrop click).
*
* Drafts are kept per-terminal in the store for the current session so
* closing and reopening the dialog doesn't lose work; they're dropped
* when the owning pane is closed.
*/
const PromptComposer: React.FC = () => {
const terminalId = useTerminalStore((s) => s.promptComposerRequest);
const draft = useTerminalStore((s) =>
terminalId ? s.composerDrafts[terminalId] ?? '' : ''
);
const setDraft = useTerminalStore((s) => s.setPromptComposerDraft);
const close = useTerminalStore((s) => s.closePromptComposer);

const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const [copied, setCopied] = useState(false);
const [submitted, setSubmitted] = useState(false);

// Autofocus + Esc-to-close while the dialog is mounted. Reset transient
// button feedback whenever a fresh composer instance opens.
useEffect(() => {
if (!terminalId) return;
setCopied(false);
setSubmitted(false);
const t = setTimeout(() => {
const el = textareaRef.current;
if (el) {
el.focus();
// Park the caret at the end so reopening with an existing draft
// doesn't trap the user typing in the middle.
el.setSelectionRange(el.value.length, el.value.length);
}
}, 0);
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
close();
}
};
window.addEventListener('keydown', onKey);
return () => {
clearTimeout(t);
window.removeEventListener('keydown', onKey);
};
}, [terminalId, close]);

const onCopy = useCallback(() => {
if (!draft) return;
try {
window.terminalAPI.clipboardWrite(draft);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
/* clipboard surface failure is non-fatal */
}
}, [draft]);

const onSubmit = useCallback(() => {
if (!terminalId || !draft) return;
// Reuse the shared paste helper so we get bracketed-paste wrapping
// and CRLFโ†’LF normalization for free (matters when users paste
// Windows-CRLF content into the composer).
const payload = prepareClipboardPaste(draft, true);
try {
window.terminalAPI.writePty(terminalId, payload);
// Sent prompts shouldn't linger in the composer - clear the draft
// so reopening for the same pane starts fresh.
setDraft(terminalId, '');
setSubmitted(true);
setTimeout(() => {
close();
}, 250);
} catch {
/* terminal may have closed under us */
}
}, [terminalId, draft, close, setDraft]);

if (!terminalId) return null;

const hasText = draft.length > 0;

return (
<div className="palette-backdrop" onClick={close} style={{ paddingTop: 60 }}>
<div
className="prompt-composer-card"
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-label="Prompt composer"
>
<div className="prompt-composer-header">
<span className="prompt-composer-title">๐Ÿ“ Prompt composer</span>
<button
className="prompt-composer-close-x"
onClick={close}
aria-label="Close"
title="Close (Esc)"
>
&times;
</button>
</div>
<textarea
ref={textareaRef}
className="prompt-composer-textarea"
value={draft}
onChange={(e) => setDraft(terminalId, e.target.value)}
placeholder="Write your prompt here. Newlines, paste, and long text all work. Use Copy or Submit when you're ready."
spellCheck={false}
/>
<div className="prompt-composer-footer">
<button
className="prompt-composer-btn"
onClick={onCopy}
disabled={!hasText}
title="Copy the text to the clipboard"
>
{copied ? 'โœ“ Copied' : '๐Ÿ“‹ Copy'}
</button>
<button
className="prompt-composer-btn prompt-composer-btn-primary"
onClick={onSubmit}
disabled={!hasText}
title="Paste into the terminal (you press Enter to send)"
>
{submitted ? 'โœ“ Sent' : 'โžค Submit'}
</button>
<button
className="prompt-composer-btn"
onClick={close}
title="Close without sending (Esc)"
>
Close
</button>
</div>
</div>
</div>
);
};

export default PromptComposer;
1 change: 1 addition & 0 deletions src/renderer/components/ShortcutsHelp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ const shortcuts = [
]},
{ category: 'AI', items: [
{ key: 'Ctrl+Shift+K', action: 'Jump to prompt in terminal' },
{ key: 'Ctrl+Alt+P', action: 'Open prompt composer' },
{ key: 'Ctrl+Shift+C', action: 'AI Sessions panel' },
]},
{ category: 'Other', items: [
Expand Down
6 changes: 6 additions & 0 deletions src/renderer/components/TerminalPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3046,6 +3046,12 @@ const TerminalPanel: React.FC<TerminalPanelProps> = ({ terminalId, floatTitleBar
useTerminalStore.getState().showPromptsForTerminal(terminalId);
}}>๐Ÿ’ฌ Show prompts <span className="context-menu-shortcut">Ctrl+Shift+K</span></button>
)}
{aiSessionId && (
<button className="context-menu-item" onClick={() => {
setPaneMenuPos(null);
useTerminalStore.getState().openPromptComposer(terminalId);
}}>๐Ÿ“ Prompt composer <span className="context-menu-shortcut">Ctrl+Alt+P</span></button>
)}
{aiSessionId && (
<button className="context-menu-item" onClick={() => {
setPaneMenuPos(null);
Expand Down
26 changes: 25 additions & 1 deletion src/renderer/hooks/useKeybindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,23 @@ function matchesCombo(event: KeyboardEvent, combo: KeyCombo): boolean {
// On macOS, Cmd (metaKey) is the primary app modifier instead of Ctrl
if (isMac) {
const shiftedKey = combo.shiftKey ? (MAC_SHIFT_MAP[eventKey] ?? eventKey) : eventKey;
// When Option/Alt is held on macOS, event.key contains the special
// character the OS produces (Option+P โ†’ "ฯ€", Option+R โ†’ "ยฎ", etc.),
// not the underlying letter. event.code stays layout-stable
// ("KeyP", "KeyR"...) so we fall back to it for letter/digit
// shortcuts. Without this, every Cmd+Option+<letter> binding
// silently no-ops on Mac.
const codeKey = (() => {
const c = event.code;
if (c.startsWith('Key') && c.length === 4) return c.slice(3).toLowerCase();
if (c.startsWith('Digit') && c.length === 6) return c.slice(5);
return '';
})();
return (
event.metaKey === combo.ctrlKey &&
event.shiftKey === combo.shiftKey &&
event.altKey === combo.altKey &&
(eventKey === combo.key || shiftedKey === combo.key)
(eventKey === combo.key || shiftedKey === combo.key || codeKey === combo.key)
);
}
return (
Expand Down Expand Up @@ -146,6 +158,9 @@ const DEFAULT_BINDINGS: Record<string, string> = {
// Ctrl+Alt+N: replace the focused pane with a fresh shell in the same
// slot. TASK-173.
'Ctrl+Alt+N': 'replaceTerminal',
// Ctrl+Alt+P: open the prompt composer for the focused pane (TASK-180).
// Ctrl+Shift+P is the command palette, so Alt instead of Shift.
'Ctrl+Alt+P': 'promptComposer',
};

export function useKeybindings(): void {
Expand Down Expand Up @@ -313,6 +328,15 @@ function dispatchAction(action: string): void {
case 'showPrompts':
if (focusedId) store.showPromptsForTerminal(focusedId);
break;
case 'promptComposer':
// AI-session-only, mirroring the pane context menu. The composer is
// mainly useful for drafting chat prompts; on a plain shell pane
// the shortcut would surprise the user.
if (focusedId) {
const inst = store.terminals.get(focusedId);
if (inst?.aiSessionId) store.openPromptComposer(focusedId);
}
break;
case 'searchPrompts':
store.togglePromptSearch();
break;
Expand Down
Loading
Loading