From bb6a8d4b7bee7311e6667a6695e25695fc7eafa9 Mon Sep 17 00:00:00 2001 From: "R.J. (Steinert) Corwin" Date: Sun, 21 Sep 2025 19:29:34 -0400 Subject: [PATCH] feat(cli): add interactive envelope authoring scaffold --- cli/spec/draft/SPEC.md | 12 + .../ADR-006-interactive-envelope-authoring.md | 76 +++ cli/spec/draft/proposed/README.md | 14 + cli/src/config/envelopeForms.js | 154 ++++++ cli/src/ui/components/EnvelopeEditor.js | 461 ++++++++++++++++++ cli/src/utils/advanced-interactive-ui.js | 86 +++- 6 files changed, 798 insertions(+), 5 deletions(-) create mode 100644 cli/spec/draft/proposed/ADR-006-interactive-envelope-authoring.md create mode 100644 cli/src/config/envelopeForms.js create mode 100644 cli/src/ui/components/EnvelopeEditor.js diff --git a/cli/spec/draft/SPEC.md b/cli/spec/draft/SPEC.md index 68f454d..865b550 100644 --- a/cli/spec/draft/SPEC.md +++ b/cli/spec/draft/SPEC.md @@ -1317,6 +1317,18 @@ The default interactive mode uses Ink (React for CLI) to provide a modern termin - Real-time participant status and typing indicators - Auto-approval rules for trusted operations +#### Interactive Envelope Authoring (ADR-006) + +The advanced UI includes a guided **Envelope Editor** that can be launched from anywhere in the session without breaking focus. + +- **Hotkeys**: `Ctrl+E` opens the generic editor, while `Ctrl+P` (or `Ctrl+Shift+P` for tool-centric flows) jumps directly to proposal-oriented templates. +- **Type Selection**: Users pick the envelope kind first; the form adapts in real time using schema metadata declared in `src/config/envelopeForms.js`. +- **Field Editing**: Each envelope kind exposes a curated set of fields (recipients, payload summaries, JSON arguments, etc.) with inline validation and helpers for lists and JSON blobs. +- **Preview**: Before submission the editor renders the final MEW envelope (including protocol metadata) so operators can confirm exactly what will be sent. +- **Drafts**: Pressing `s`/`d` in the preview saves the envelope JSON to `.mew/drafts/-.json`, allowing later reuse via a forthcoming draft browser. + +The editor is implemented as an Ink overlay component (`EnvelopeEditor`) that suspends the main input composer while active, ensuring a focused authoring workflow that still shares history and validation utilities with the rest of the UI. + **Architecture:** - Message history uses Ink's `Static` component for native scrolling - Persistent bottom panel stays fixed during scrolling diff --git a/cli/spec/draft/proposed/ADR-006-interactive-envelope-authoring.md b/cli/spec/draft/proposed/ADR-006-interactive-envelope-authoring.md new file mode 100644 index 0000000..631222f --- /dev/null +++ b/cli/spec/draft/proposed/ADR-006-interactive-envelope-authoring.md @@ -0,0 +1,76 @@ +# ADR-006: Interactive Envelope Authoring in the MEW Protocol CLI + +## Status +Proposed + +## Date +2025-09-21 + +## Context + +The MEW Protocol CLI currently supports manual construction and submission of envelopes using either direct YAML/JSON or command-line arguments. While powerful, this process can be error-prone and unintuitive, especially for: + +- Non-technical users (e.g., human reviewers or analysts) +- High-frequency use cases (e.g., streaming movement updates or chat events) +- Envelope types with deeply nested or dynamic payloads (e.g., `stream/open`, `tool/request`) + +To support faster iteration, richer interactivity, and on-demand control of agents and streams, we propose adding an interactive CLI mode for envelope creation and review. + +## Decision + +We will implement an **interactive envelope authoring interface** within the MEW CLI using a text-based UI framework (e.g., [Ink](https://github.com/vadimdemedes/ink)). This feature will allow users to: + +- Trigger envelope creation via keyboard shortcuts (e.g., `Ctrl+E`, `Ctrl+P`) +- Walk through a type-specific form to populate envelope fields +- Preview the final envelope JSON +- Send the envelope or save it as a draft + +### Core Capabilities + +- **Hotkey activation**: + - `Ctrl+E`: Launch generic envelope editor + - `Ctrl+P`: Shortcut for `message/propose` or `tool/request` + +- **Type-aware forms**: + - Envelope type is selected first + - Form updates dynamically based on schema (from config) + - Supports nested field editing (e.g. `schema`, `payload` objects) + +- **Schema-driven configuration**: + - Schema definitions are built into the CLI and reflect the official MEW Protocol spec + - Each supported envelope type has a corresponding form definition derived from the protocol type system + - Enables type-safe editing and validation at runtime without external config files + +- **Draft handling**: + - Drafts can be saved to `.mew/drafts/*.json` + - CLI can list/load/edit previously saved drafts + +## Consequences + +### Pros +- Enables envelope construction with reduced errors +- Supports new UX for human-in-the-loop workflows +- Makes the CLI viable for power users and novices alike +- Allows fast iteration of complex envelope types (e.g., live streaming, proposal flows) + +### Cons +- Increases CLI implementation complexity +- Requires consistent schema updates to reflect protocol changes +- Terminal-only; not yet accessible via GUI or web + +## Alternatives Considered + +- Static template generators (e.g., `mew gen stream/open`) — lacks dynamic interactivity +- Full GUI app — potentially overkill and inconsistent with terminal-first goals +- Read-only CLI preview — useful, but doesn't help with authoring + +## Open Questions +- Should we allow multiple envelopes to be composed/sent in sequence? +- How will this interact with proposal approval/denial tools or gateways? +- Should we allow live editing of matchers within the CLI? + +## Next Steps +- Define schema format for `envelope-forms.json` +- Build `EnvelopeEditor` component using Ink or similar +- Wire up hotkey handling and matcher trigger preview +- Add integration tests for form flow + envelope validation diff --git a/cli/spec/draft/proposed/README.md b/cli/spec/draft/proposed/README.md index fc3dde2..4b86d25 100644 --- a/cli/spec/draft/proposed/README.md +++ b/cli/spec/draft/proposed/README.md @@ -70,6 +70,20 @@ All ADRs in this directory are currently **proposed** and under review. They doc - ⚠️ Breaking change requiring client migration - ⚠️ Temporary dual-format complexity during transition +--- + +### [ADR-006: Interactive Envelope Authoring in the MEW Protocol CLI](./ADR-006-interactive-envelope-authoring.md) +**Decision**: Add an Ink-powered interactive authoring flow for composing and reviewing MEW envelopes inside the CLI. + +**Context**: Manual JSON entry is error-prone for complex envelopes (e.g., streaming or tool interactions) and slows down human-in-the-loop workflows. + +**Impact**: +- ✅ Keyboard shortcuts launch guided forms for common envelope kinds +- ✅ Schema-driven prompts reduce mistakes and improve validation +- ✅ Drafts can be saved and resumed from `.mew/drafts/` +- ⚠️ Requires ongoing maintenance to track protocol schema updates +- ⚠️ Adds UI complexity that must work in both debug and advanced modes + ## Architecture Overview The ADRs collectively document a **layered architecture** that separates protocol implementation from operational concerns: diff --git a/cli/src/config/envelopeForms.js b/cli/src/config/envelopeForms.js new file mode 100644 index 0000000..cceb7b7 --- /dev/null +++ b/cli/src/config/envelopeForms.js @@ -0,0 +1,154 @@ +/** + * Schema-driven envelope form definitions for the interactive editor. + * + * Each entry describes the base message structure and field prompts + * for a supported MEW envelope kind. + */ + +const envelopeForms = { + 'message/propose': { + label: 'message/propose', + description: 'Propose a message or decision for review by human participants.', + base: { + kind: 'message/propose', + to: [], + payload: { + summary: '', + body: '', + tags: [] + } + }, + fields: [ + { + path: 'to', + label: 'Recipients', + type: 'list', + description: 'Comma-separated participant IDs that should receive the proposal (optional).', + defaultValue: [] + }, + { + path: 'payload.summary', + label: 'Summary', + type: 'string', + description: 'Short headline that will be displayed in inboxes and notifications.', + required: true, + defaultValue: '' + }, + { + path: 'payload.body', + label: 'Body', + type: 'string', + description: 'Full proposal body. Markdown is supported by most clients.', + multiline: true, + defaultValue: '' + }, + { + path: 'payload.tags', + label: 'Tags', + type: 'list', + description: 'Optional tags to help downstream automation filter proposals.', + defaultValue: [] + } + ] + }, + 'tool/request': { + label: 'tool/request', + description: 'Request that a tool be executed on behalf of the participant.', + base: { + kind: 'tool/request', + to: [], + payload: { + name: '', + arguments: {}, + context: '' + } + }, + fields: [ + { + path: 'to', + label: 'Target Participants', + type: 'list', + description: 'Comma-separated participant IDs to route the tool request to (optional).', + defaultValue: [] + }, + { + path: 'payload.name', + label: 'Tool Name', + type: 'string', + description: 'Identifier of the tool capability to invoke (e.g., `mcp://fs/read`).', + required: true, + defaultValue: '' + }, + { + path: 'payload.arguments', + label: 'Arguments (JSON)', + type: 'json', + description: 'JSON object containing the call arguments for the tool.', + defaultValue: {} + }, + { + path: 'payload.context', + label: 'Context', + type: 'string', + description: 'Optional human-readable context or justification for the tool run.', + defaultValue: '' + } + ] + }, + 'stream/open': { + label: 'stream/open', + description: 'Open a new event stream for structured, high-frequency updates.', + base: { + kind: 'stream/open', + to: [], + payload: { + stream: { + name: '', + kind: 'custom', + channel: '', + metadata: {} + } + } + }, + fields: [ + { + path: 'to', + label: 'Recipients', + type: 'list', + description: 'Comma-separated participant IDs that should receive the stream notifications.', + defaultValue: [] + }, + { + path: 'payload.stream.name', + label: 'Stream Name', + type: 'string', + description: 'Human-readable name for the stream (e.g., `movement-tracking`).', + required: true, + defaultValue: '' + }, + { + path: 'payload.stream.kind', + label: 'Stream Kind', + type: 'string', + description: 'Stream type (e.g., `position`, `chat`, or custom taxonomy).', + defaultValue: 'custom' + }, + { + path: 'payload.stream.channel', + label: 'Channel', + type: 'string', + description: 'Optional routing key for multiplexed stream consumers.', + defaultValue: '' + }, + { + path: 'payload.stream.metadata', + label: 'Metadata (JSON)', + type: 'json', + description: 'JSON object with implementation-specific metadata for downstream services.', + defaultValue: {} + } + ] + } +}; + +module.exports = envelopeForms; diff --git a/cli/src/ui/components/EnvelopeEditor.js b/cli/src/ui/components/EnvelopeEditor.js new file mode 100644 index 0000000..e7efe5c --- /dev/null +++ b/cli/src/ui/components/EnvelopeEditor.js @@ -0,0 +1,461 @@ +const React = require('react'); +const { Box, Text, useInput } = require('ink'); +const envelopeForms = require('../../config/envelopeForms'); + +const { useState, useMemo, useEffect } = React; + +function deepClone(value) { + if (value === undefined) return undefined; + return JSON.parse(JSON.stringify(value)); +} + +function setValueAtPath(target, path, value) { + if (!path) { + return; + } + const segments = path.split('.'); + let current = target; + for (let i = 0; i < segments.length; i += 1) { + const segment = segments[i]; + if (i === segments.length - 1) { + current[segment] = value; + return; + } + if (current[segment] === undefined || current[segment] === null || typeof current[segment] !== 'object') { + current[segment] = {}; + } + current = current[segment]; + } +} + +function getValueAtPath(target, path) { + if (!path) { + return undefined; + } + const segments = path.split('.'); + let current = target; + for (const segment of segments) { + if (current === undefined || current === null) { + return undefined; + } + current = current[segment]; + } + return current; +} + +function valueToString(value, field) { + if (value === undefined || value === null) { + if (field && field.defaultValue !== undefined) { + return field.type === 'json' + ? JSON.stringify(field.defaultValue, null, 2) + : Array.isArray(field.defaultValue) + ? field.defaultValue.join(', ') + : String(field.defaultValue ?? ''); + } + return ''; + } + + if (field && field.type === 'json') { + try { + return JSON.stringify(value, null, 2); + } catch (err) { + return String(value); + } + } + + if (field && field.type === 'list') { + if (Array.isArray(value)) { + return value.join(', '); + } + return String(value ?? ''); + } + + if (typeof value === 'object') { + try { + return JSON.stringify(value, null, 2); + } catch (err) { + return String(value); + } + } + + return String(value ?? ''); +} + +function parseFieldValue(field, rawValue) { + const trimmed = rawValue.trim(); + + if (field.required && trimmed.length === 0) { + return { error: `${field.label} is required.` }; + } + + if (field.type === 'list') { + if (trimmed.length === 0) { + return { value: [] }; + } + const list = rawValue + .split(',') + .map(entry => entry.trim()) + .filter(Boolean); + return { value: list }; + } + + if (field.type === 'json') { + if (trimmed.length === 0) { + if (field.defaultValue !== undefined) { + return { value: deepClone(field.defaultValue) }; + } + return { value: {} }; + } + try { + return { value: JSON.parse(rawValue) }; + } catch (err) { + return { error: `Invalid JSON: ${err.message}` }; + } + } + + if (field.type === 'boolean') { + if (trimmed.length === 0 && field.required) { + return { error: `${field.label} is required.` }; + } + const normalized = trimmed.toLowerCase(); + if (['true', 't', 'yes', 'y', '1'].includes(normalized)) { + return { value: true }; + } + if (['false', 'f', 'no', 'n', '0'].includes(normalized)) { + return { value: false }; + } + if (trimmed.length === 0) { + return { value: false }; + } + return { error: `Invalid boolean. Enter yes/no or true/false.` }; + } + + // Default to returning the raw string (including whitespace) + return { value: rawValue }; +} + +function initializeForm(form) { + if (!form) { + return {}; + } + + const base = deepClone(form.base || {}); + + if (Array.isArray(form.fields)) { + for (const field of form.fields) { + if (!field.path) continue; + const existing = getValueAtPath(base, field.path); + if (existing === undefined) { + if (field.defaultValue !== undefined) { + setValueAtPath(base, field.path, deepClone(field.defaultValue)); + } else if (field.type === 'list') { + setValueAtPath(base, field.path, []); + } else if (field.type === 'json') { + setValueAtPath(base, field.path, {}); + } else { + setValueAtPath(base, field.path, ''); + } + } + } + } + + return base; +} + +function safeStringify(value) { + try { + return JSON.stringify(value, null, 2); + } catch (err) { + return String(value); + } +} + +function EnvelopeEditor({ + initialType = null, + onClose = () => {}, + onSubmit = () => {}, + onSaveDraft, + wrapEnvelope, +}) { + const formList = useMemo( + () => Object.entries(envelopeForms).map(([type, definition]) => ({ type, ...definition })), + [] + ); + + const initialIndex = useMemo(() => { + if (initialType) { + const match = formList.findIndex(form => form.type === initialType); + if (match >= 0) { + return match; + } + } + return 0; + }, [formList, initialType]); + + const [selectedIndex, setSelectedIndex] = useState(initialIndex); + const [step, setStep] = useState(initialType ? 'fields' : 'type'); + const [values, setValues] = useState(() => initializeForm(formList[initialIndex])); + const [fieldIndex, setFieldIndex] = useState(0); + const [inputValue, setInputValue] = useState(''); + const [error, setError] = useState(null); + const [status, setStatus] = useState(null); + + const activeForm = formList[selectedIndex] || null; + const fields = activeForm?.fields || []; + + useEffect(() => { + const form = formList[selectedIndex]; + const freshValues = initializeForm(form); + setValues(freshValues); + setFieldIndex(0); + if (form && form.fields && form.fields.length > 0) { + const firstField = form.fields[0]; + setInputValue(valueToString(getValueAtPath(freshValues, firstField.path), firstField)); + } else { + setInputValue(''); + } + setError(null); + setStatus(null); + }, [selectedIndex, formList]); + + useEffect(() => { + if (step === 'fields') { + if (!fields.length) { + setStep('preview'); + return; + } + const field = fields[fieldIndex]; + if (!field) { + setStep('preview'); + return; + } + setInputValue(valueToString(getValueAtPath(values, field.path), field)); + } + }, [step, fieldIndex, fields, values]); + + useEffect(() => { + if (step !== 'preview') { + setStatus(null); + } + setError(null); + }, [step]); + + const handleSubmitField = () => { + const field = fields[fieldIndex]; + if (!field) { + setStep('preview'); + return; + } + + const { value, error: parseError } = parseFieldValue(field, inputValue); + if (parseError) { + setError(parseError); + return; + } + + setValues(prev => { + const next = deepClone(prev); + setValueAtPath(next, field.path, value); + return next; + }); + + setError(null); + + if (fieldIndex >= fields.length - 1) { + setStep('preview'); + } else { + setFieldIndex(fieldIndex + 1); + } + }; + + const handleSaveDraft = () => { + if (!onSaveDraft) { + return; + } + try { + const result = onSaveDraft(values); + if (result && typeof result.then === 'function') { + setStatus('Saving draft...'); + result + .then(path => { + setStatus(path ? `Draft saved to ${path}` : 'Draft saved.'); + }) + .catch(err => { + setStatus(`Failed to save draft: ${err.message}`); + }); + } else if (typeof result === 'string') { + setStatus(`Draft saved to ${result}`); + } else if (result && typeof result === 'object') { + const displayPath = result.relativePath || result.path; + setStatus(displayPath ? `Draft saved to ${displayPath}` : 'Draft saved.'); + } else { + setStatus('Draft saved.'); + } + } catch (err) { + setStatus(`Failed to save draft: ${err.message}`); + } + }; + + const closeEditor = () => { + onClose(); + }; + + const sendEnvelope = () => { + onSubmit(values); + onClose(); + }; + + useInput((input, key) => { + if (step === 'type') { + if (key.escape) { + closeEditor(); + return; + } + if (key.upArrow) { + setSelectedIndex(prev => (prev - 1 + formList.length) % formList.length); + return; + } + if (key.downArrow) { + setSelectedIndex(prev => (prev + 1) % formList.length); + return; + } + if (key.number !== undefined && formList[key.number - 1]) { + setSelectedIndex(key.number - 1); + return; + } + if (key.return) { + if (fields.length === 0) { + setStep('preview'); + } else { + setStep('fields'); + } + } + return; + } + + if (step === 'fields') { + const field = fields[fieldIndex]; + if (key.escape) { + closeEditor(); + return; + } + if (!field) { + setStep('preview'); + return; + } + if (field.multiline && key.shift && key.return) { + setInputValue(prev => `${prev}\n`); + return; + } + if (key.return) { + handleSubmitField(); + return; + } + if (key.backspace) { + setInputValue(prev => prev.slice(0, -1)); + return; + } + if (key.leftArrow && inputValue.length > 0 && !field.multiline) { + return; + } + if (!key.ctrl && !key.meta && input) { + setInputValue(prev => prev + input); + } + return; + } + + if (step === 'preview') { + if (key.escape) { + closeEditor(); + return; + } + if (key.return) { + sendEnvelope(); + return; + } + if (input && input.toLowerCase() === 'e') { + setStep('fields'); + setFieldIndex(0); + return; + } + if (input && ['s', 'd'].includes(input.toLowerCase())) { + handleSaveDraft(); + } + } + }); + + const previewEnvelope = wrapEnvelope ? wrapEnvelope(values) : values; + const previewJson = safeStringify(previewEnvelope); + + return ( + React.createElement(Box, { + flexDirection: 'column', + borderStyle: 'round', + borderColor: 'magenta', + paddingX: 2, + paddingY: 1, + width: '90%', + marginLeft: 'auto', + marginRight: 'auto' + }, + React.createElement(Text, { color: 'magenta', bold: true }, 'Interactive Envelope Editor'), + activeForm && React.createElement(Text, { color: 'gray' }, `${activeForm.type} — ${activeForm.description}`), + + step === 'type' && React.createElement(Box, { flexDirection: 'column', marginTop: 1 }, + React.createElement(Text, null, 'Select envelope kind to author:'), + formList.map((form, index) => ( + React.createElement(Text, { + key: form.type, + color: index === selectedIndex ? 'green' : 'white' + }, `${index === selectedIndex ? '❯' : ' '} ${form.type} — ${form.description}`) + )), + React.createElement(Box, { marginTop: 1 }, + React.createElement(Text, { color: 'gray' }, 'Use ↑/↓ to choose, numbers 1-9 to jump, Enter to continue, Esc to cancel') + ) + ), + + step === 'fields' && fields[fieldIndex] && React.createElement(Box, { flexDirection: 'column', marginTop: 1 }, + React.createElement(Text, { color: 'cyan' }, `Field ${fieldIndex + 1} of ${fields.length}: ${fields[fieldIndex].label}`), + fields[fieldIndex].description && React.createElement(Text, { color: 'gray' }, fields[fieldIndex].description), + React.createElement(Box, { + borderStyle: 'single', + paddingX: 1, + paddingY: fields[fieldIndex].multiline ? 1 : 0, + marginTop: 1 + }, + React.createElement(Text, { wrap: 'wrap' }, inputValue || (fields[fieldIndex].required ? '' : '')) + ), + React.createElement(Box, { marginTop: 1 }, + React.createElement(Text, { color: 'gray' }, fields[fieldIndex].multiline + ? 'Shift+Enter for newline, Enter to confirm, Esc to cancel' + : 'Type to edit, Enter to confirm, Esc to cancel') + ), + error && React.createElement(Box, { marginTop: 1 }, + React.createElement(Text, { color: 'red' }, error) + ) + ), + + step === 'preview' && React.createElement(Box, { flexDirection: 'column', marginTop: 1 }, + React.createElement(Text, { color: 'cyan' }, 'Preview Envelope'), + React.createElement(Box, { + borderStyle: 'single', + paddingX: 1, + paddingY: 1, + marginTop: 1, + maxHeight: 20 + }, + React.createElement(Text, { wrap: 'wrap' }, previewJson) + ), + React.createElement(Box, { marginTop: 1 }, + React.createElement(Text, { color: 'gray' }, 'Enter to send • s/d to save draft • e to edit fields • Esc to cancel') + ) + ), + + status && React.createElement(Box, { marginTop: 1 }, + React.createElement(Text, { color: 'yellow' }, status) + ) + ) + ); +} + +module.exports = EnvelopeEditor; diff --git a/cli/src/utils/advanced-interactive-ui.js b/cli/src/utils/advanced-interactive-ui.js index b2cf4a8..828af4e 100644 --- a/cli/src/utils/advanced-interactive-ui.js +++ b/cli/src/utils/advanced-interactive-ui.js @@ -11,8 +11,10 @@ const React = require('react'); const { render, Box, Text, Static, useInput, useApp, useFocus } = require('ink'); const { useState, useEffect, useRef } = React; const { v4: uuidv4 } = require('uuid'); +const fs = require('fs'); +const path = require('path'); const EnhancedInput = require('../ui/components/EnhancedInput'); -const SimpleInput = require('../ui/components/SimpleInput'); // Temporary for debugging +const EnvelopeEditor = require('../ui/components/EnvelopeEditor'); /** * Main Advanced Interactive UI Component @@ -25,6 +27,8 @@ function AdvancedInteractiveUI({ ws, participantId, spaceId }) { const [verbose, setVerbose] = useState(false); const [activeReasoning, setActiveReasoning] = useState(null); // Track active reasoning sessions const [grantedCapabilities, setGrantedCapabilities] = useState(new Map()); // Track granted capabilities by participant + const [isEnvelopeEditorOpen, setEnvelopeEditorOpen] = useState(false); + const [envelopeEditorType, setEnvelopeEditorType] = useState(null); const { exit } = useApp(); // Environment configuration @@ -65,6 +69,28 @@ function AdvancedInteractiveUI({ ws, participantId, spaceId }) { }; }, [ws, exit]); + useInput((input, key) => { + if (isEnvelopeEditorOpen) { + return; + } + if (pendingOperation || showHelp) { + return; + } + + const keyName = (key && key.name ? key.name : input || '').toLowerCase(); + + if (key.ctrl && keyName === 'e') { + setEnvelopeEditorType(null); + setEnvelopeEditorOpen(true); + return; + } + + if (key.ctrl && keyName === 'p') { + setEnvelopeEditorType(key.shift ? 'tool/request' : 'message/propose'); + setEnvelopeEditorOpen(true); + } + }); + const addMessage = (message, sent = false) => { setMessages(prev => [...prev, { id: message.id || uuidv4(), @@ -74,6 +100,11 @@ function AdvancedInteractiveUI({ ws, participantId, spaceId }) { }]); }; + const closeEnvelopeEditor = () => { + setEnvelopeEditorOpen(false); + setEnvelopeEditorType(null); + }; + const handleIncomingMessage = (message) => { // Filter out echo messages - don't show messages from ourselves coming back // EXCEPT for errors and grant acknowledgments which we want to see @@ -246,6 +277,10 @@ function AdvancedInteractiveUI({ ws, participantId, spaceId }) { }]); }; + const handleEnvelopeSubmit = (message) => { + sendMessage(message); + }; + const sendChat = (text) => { sendMessage({ kind: 'chat', @@ -323,6 +358,38 @@ function AdvancedInteractiveUI({ ws, participantId, spaceId }) { }; }; + const saveEnvelopeDraftToDisk = (message) => { + try { + const mewDir = path.join(process.cwd(), '.mew'); + const draftsDir = path.join(mewDir, 'drafts'); + fs.mkdirSync(draftsDir, { recursive: true }); + + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const safeKind = (message.kind || 'draft').replace(/[\\/]/g, '-'); + const fileName = `${timestamp}-${safeKind}.json`; + const filePath = path.join(draftsDir, fileName); + + const envelope = wrapEnvelope(message); + fs.writeFileSync(filePath, JSON.stringify(envelope, null, 2)); + + const relativePath = path.relative(process.cwd(), filePath); + addMessage({ + kind: 'system/info', + from: 'system', + payload: { text: `💾 Draft saved to ${relativePath}` } + }, false); + + return { path: filePath, relativePath }; + } catch (error) { + addMessage({ + kind: 'system/error', + from: 'system', + payload: { text: `Failed to save draft: ${error.message}` } + }, false); + throw error; + } + }; + const isValidEnvelope = (obj) => { return obj && obj.protocol === 'mew/v0.3' && obj.id && obj.ts && obj.kind; }; @@ -463,18 +530,27 @@ function AdvancedInteractiveUI({ ws, participantId, spaceId }) { showHelp && React.createElement(HelpModal, { onClose: () => setShowHelp(false) }), - + + // Envelope Editor Overlay + isEnvelopeEditorOpen && React.createElement(EnvelopeEditor, { + initialType: envelopeEditorType, + onClose: closeEnvelopeEditor, + onSubmit: handleEnvelopeSubmit, + onSaveDraft: saveEnvelopeDraftToDisk, + wrapEnvelope + }), + // Reasoning Status activeReasoning && React.createElement(ReasoningStatus, { reasoning: activeReasoning }), - + // Enhanced Input Component (disabled when dialog is shown) React.createElement(EnhancedInput, { onSubmit: processInput, - placeholder: 'Type a message or /help for commands...', + placeholder: 'Type a message, /help, or press Ctrl+E for the editor...', multiline: true, // Enable multi-line for Shift+Enter support - disabled: pendingOperation !== null || showHelp, + disabled: pendingOperation !== null || showHelp || isEnvelopeEditorOpen, history: commandHistory, onHistoryChange: setCommandHistory, prompt: '> ',