diff --git a/README.md b/README.md index 99b1b65..99479d7 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,131 @@ # SIGIL-PS Core -This is the repository for the backend component of the conversational agent SIGIL-PS (or just Sigil), developed by NAU's RESHAPE Lab. -## Setup -The server and database have been containerized via Docker. You will need to have Docker installed to run it. +This is the repository for the backend component of the conversational agent **SIGIL-PS** (or just Sigil), developed by NAU's RESHAPE Lab. It provides the API, LLM (Sigil/DSPy), and optional web UI that the VS Code extension and other clients use. -Once you have Docker, you will have to set up your environment variables. Copy the `.env.template` file, rename it `.env`, and fill in the missing variables. +## Project overview -Then, to run it, run the following command in the root directory of the project: +Sigil is a conversational agent for novice programming students. This **core** repo contains: + +- **API** – Flask REST API for chat, feedback, personalization, personas, and users +- **LLM** – DSPy-based Sigil module for tutoring responses +- **UI** – React (Vite) chat interface, served by the API or embedded in the VS Code extension + +## Architecture + +```mermaid +flowchart LR + subgraph clients [Clients] + Ext[VS Code Extension] + Browser[Browser / static UI] + end + subgraph core [sigil-ps-core] + API[Flask API] + Sigil[LLM Sigil/DSPy] + end + DB[(MySQL)] + Ext --> API + Browser --> API + API --> Sigil + API --> DB +``` + +- **Flask API** handles HTTP requests, manages sessions, and calls the LLM. +- **Sigil (DSPy)** produces tutoring responses; personas and personalization are applied here. +- **MySQL** stores users, personalization, personas, and related data. +- The React app in `ui/` is built to `ui/dist` and served by Flask at `/` in production. + +## Prerequisites + +- **Docker** (recommended for local run), or +- **Python 3.10+** and **MySQL** (or Docker for DB only) if running the API locally +- **Node.js** and **pnpm** (or npm) if you need to build or develop the UI + +## Environment + +Copy `api/.env.template` to `api/.env` and set: + +| Variable | Description | +|----------|-------------| +| `OPENAI_API_KEY` | Required for the LLM (DSPy/OpenAI). Also needed for evaluation. | +| `MYSQL_HOST` | MySQL host (e.g. `localhost` or `db` when using Docker Compose). | +| `MYSQL_DATABASE` | Database name (e.g. `sigil_db`). | +| `MYSQL_USER` | MySQL user. | +| `MYSQL_PASSWORD` | MySQL password. | +| `MYSQL_ROOT_PASSWORD` | Optional; used by some DB setup flows. | + +**Note:** When using Docker Compose, the Compose file overrides DB-related env for the API container (local/test only; do not use this setup for production). + +## Running + +### Docker (recommended for local/test) + +From the **root of this repo** (sigil-ps-core): ```bash docker-compose up --build ``` -The server should be running on port 5000. \ No newline at end of file +The API is available at **http://localhost:80**. The Compose setup is for **local testing only**; do not use it for production deployment. + +### Local API (without Docker) + +1. Ensure MySQL is running and the database exists (e.g. create `sigil_db`). +2. Copy `api/.env.template` to `api/.env` and set `MYSQL_HOST`, `MYSQL_USER`, `MYSQL_PASSWORD`, and `OPENAI_API_KEY`. +3. From the repo root: + + ```bash + pip install -r requirements.txt + set FLASK_APP=api.main + flask run --host=0.0.0.0 --port=5000 + ``` + + (On Unix/macOS use `export FLASK_APP=api.main`.) + +The API runs on port 5000. For production-style serving (e.g. Gunicorn), see your deployment docs. + +### UI + +- **Production:** The Flask app serves the built UI from `ui/dist` at `/`. Build the UI from the `ui/` directory: `pnpm install && pnpm run build` (see [ui/README.md](ui/README.md)). +- **Development:** Run the UI dev server from `ui/`: `pnpm install && pnpm run dev` (e.g. http://localhost:5173). Point the UI at your local API if needed via its env (see `ui/.env.template`). + +## Testing and evaluation + +### CLI chat (interactive) + +Use [test/cl_chat.py](test/cl_chat.py) to exercise Sigil locally with optional code, history, and feedback (no API or DB required). From the repo root, with `OPENAI_API_KEY` set: + +```bash +python test/cl_chat.py +``` + +### LLM evaluation + +The evaluation pipeline scores model outputs with configurable datasets and metrics (DeepEval/GEval). Full details: [docs/evaluation.md](docs/evaluation.md). + +From the `test/` directory, with `OPENAI_API_KEY` set: + +```bash +cd test +python evaluation.py tests/example_test.json results/my_output.json +``` + +Paths in the test config JSON are relative to the current working directory; running from `test/` is recommended. + +### API / unit tests + +There are no pytest (or other) API unit tests in this repo at present. The primary automated check is the **evaluation** flow above. To add API tests, use a standard Python test runner (e.g. pytest) against the Flask app. + +## Project layout + +| Path | Description | +|------|-------------| +| `api/` | Flask app, routes (prompt, feedback, personalization, personas, users), DB config and utilities. | +| `llm/` | DSPy Sigil module and personas. | +| `test/` | CLI chat script, evaluation script, dataset/metric definitions, and test configs. | +| `ui/` | React (Vite) chat UI; built output is served by the API. | +| `docs/` | Documentation (e.g. evaluation). | + +## Links + +- **VS Code extension:** See the [sigil-ps-vscode](../sigil-ps-vscode) sibling repo for the extension that talks to this API. +- **Evaluation:** [docs/evaluation.md](docs/evaluation.md) for dataset format, metrics, and running evaluation. diff --git a/api/__init__.py b/api/__init__.py index 82c5818..50e8623 100644 --- a/api/__init__.py +++ b/api/__init__.py @@ -12,36 +12,33 @@ from .routes.users import users_bp from .extensions import mysql + def create_app(): static_dir = os.path.join(os.path.dirname(__file__), "../ui/dist") - app = Flask(__name__, static_folder=static_dir, static_url_path='') + app = Flask(__name__, static_folder=static_dir, static_url_path="") litellm.cache = None # Configure the app - app.config['MYSQL_HOST'] = Config.HOST - app.config['MYSQL_USER'] = Config.USER - app.config['MYSQL_PASSWORD'] = Config.PASSWORD - app.config['MYSQL_DB'] = Config.DATABASE - app.config['MYSQL_CHARSET'] = Config.CHARSET - app.config['MYSQL_USE_UNICODE'] = Config.UNICODE + app.config["MYSQL_HOST"] = Config.HOST + app.config["MYSQL_USER"] = Config.USER + app.config["MYSQL_PASSWORD"] = Config.PASSWORD + app.config["MYSQL_DB"] = Config.DATABASE + app.config["MYSQL_CHARSET"] = Config.CHARSET + app.config["MYSQL_USE_UNICODE"] = Config.UNICODE # SSL config (only if set, which would only not be set in development) if Config.SSL_CA: - app.config['MYSQL_CUSTOM_OPTIONS'] = { - 'ssl': { - 'ca': Config.SSL_CA - }, - 'ssl_mode': 'VERIFY_CA' + app.config["MYSQL_CUSTOM_OPTIONS"] = { + "ssl": {"ca": Config.SSL_CA}, + "ssl_mode": "VERIFY_CA", } # Set up logging for the app logging.basicConfig( level=logging.INFO, - format='[%(asctime)s] [%(levelname)s] [%(module)s] %(message)s', # Log format - handlers=[ - logging.StreamHandler() - ] + format="[%(asctime)s] [%(levelname)s] [%(module)s] %(message)s", # Log format + handlers=[logging.StreamHandler()], ) app.logger = logging.getLogger(__name__) @@ -62,11 +59,11 @@ def create_app(): app.logger.info("CORS initialized.") # Register blueprints - app.register_blueprint(prompt_bp, url_prefix='/api') - app.register_blueprint(feedback_bp, url_prefix='/api') - app.register_blueprint(personalization_bp, url_prefix='/api') - app.register_blueprint(personas_bp, url_prefix='/api') - app.register_blueprint(users_bp, url_prefix='/api') + app.register_blueprint(prompt_bp, url_prefix="/api") + app.register_blueprint(feedback_bp, url_prefix="/api") + app.register_blueprint(personalization_bp, url_prefix="/api") + app.register_blueprint(personas_bp, url_prefix="/api") + app.register_blueprint(users_bp, url_prefix="/api") # Log request info @app.before_request @@ -82,13 +79,13 @@ def log_request_info(): current_app.logger.info(f"JSON Body:\n{pretty_body}") # Serve React static files - @app.route('/', defaults={'path': ''}) - @app.route('/') + @app.route("/", defaults={"path": ""}) + @app.route("/") def serve_react(path): if path != "" and os.path.exists(os.path.join(app.static_folder, path)): return send_from_directory(app.static_folder, path) else: # Serve index.html for React Router - return send_from_directory(app.static_folder, 'index.html') + return send_from_directory(app.static_folder, "index.html") - return app \ No newline at end of file + return app diff --git a/docs/evaluation.md b/docs/evaluation.md index 06e7aa4..35be137 100644 --- a/docs/evaluation.md +++ b/docs/evaluation.md @@ -1,60 +1,98 @@ # SIGIL-PS Evaluation -We have built our own evaluation module for Sigil. It allows one to define reusable datasets and metrics in JSON files, and then define tests to run composed of different combinations of datasets and metrics. It is essentially just a wrapper around the DSPy evaluation module. Behind the scenes, it uses another LLM to evaluate chatbot responses. +We have built our own evaluation module for Sigil. It allows one to define reusable datasets and metrics in JSON files, and then define tests to run composed of datasets and metrics. The implementation uses **DeepEval** (GEval) and an LLM to score chatbot responses. ## Definitions -**Dataset** - a set of example inputs, and optionally example outputs. The example inputs are used as input messages to the chatbot, and if provided, metrics can compare the chatbot's outputs to the example outputs. +**Dataset** – A JSON file containing a set of data points. Each point has an `input` (student message), optional `code`, an expected/gold `output`, and an `actual_output` (model response to be scored). Datasets used by this script are typically **result** datasets: they already contain `actual_output` from a prior run or export (e.g. from the API or a batch job). -**Metric** - some measure by which to judge the chatbot's output. Metrics are mainly defined by a description of what the evaluator LLM should be looking for, and a "score" object which defines how it should be measured (e.g. a scale of 1-5, a boolean value, or percentage) +**Metric** – A JSON file defining how to score a response (e.g. correctness, tutor similarity). Metrics are driven by a `metric_description` and optional `score` metadata. The evaluator uses DeepEval’s GEval and an LLM to produce scores. -**Test** - a collection of datasets and metrics. The program will evaluate each dataset with each metric. +**Test config** – A JSON file that points to **one** dataset file and **one or more** metric files. The program evaluates every data point in that dataset with each metric and writes aggregated results to an output file. -## Creating Datasets +## Run command -### Example JSON File +From the **`test/`** directory (paths in the test config are relative to the current working directory): + +```bash +cd test +python evaluation.py +``` + +- **test_config.json** – Path to the test config file (see below). +- **output.json** – Path where the script will write the evaluation results (per-item scores and overall metric averages). + +You must set **`OPENAI_API_KEY`** in your environment; the evaluator LLM (GEval) uses it. + +## Test config JSON + +The test config file has two fields: + +| Field | Type | Description | +|-----------|--------|-------------| +| `datasets`| string | **Single** path to the dataset JSON file (e.g. `test_cases/cs1qa_small_results_v1-0.json`). | +| `metrics` | array | Array of paths to metric JSON files (e.g. `["metrics/similarity.json"]`). | + +Example ([test/tests/example_test.json](../test/tests/example_test.json)): ```json { - "name": "Example Dataset", - "config": { - "example_outputs": true - }, - "data": [ - { - "input": "What is a pointer?", - "output": "A pointer is..." - }, - { - "input": "Give me the answer", - "output": "No" - } - ] + "metrics": ["metrics/similarity.json"], + "datasets": "test_cases/cs1qa_small_results_v1-0.json" } ``` -### Fields +Paths are relative to the directory from which you run `evaluation.py`; running from `test/` is recommended. + +## Dataset format + +The dataset file must match what [evaluation.py](../test/evaluation.py) and [dataset_util.py](../test/dataset_util.py) expect. + +### Top-level fields + +- `name` – Descriptive name for the dataset. +- `config` – Optional metadata (e.g. `example_outputs: true`). +- `data` – Array of data points (see below). + +### Data point fields -- `name` - a descriptive name for the dataset -- `config` - an object with some useful metadata - - `example_outputs` - whether or not the dataset contains example outputs. The data itself must be consistent with this setting; if it is true, all data points must have example outputs -- `data` - an array of objects representing the actual data points in the dataset. The inputs and outputs must be named 'input' and 'output' respectively. +Each item in `data` must include: -## Creating Metrics +| Field | Description | +|-----------------|-------------| +| `input` | Student message (prompt). | +| `output` | Expected/gold response (used as reference by the metric). | +| `actual_output` | Model response to be scored (must already be filled). | +| `code` | Optional; student code context if the metric uses it. | -### Example JSON File +These datasets are usually **result** datasets: `actual_output` is already populated by a previous pipeline (e.g. running the model on `input`/`code` and saving the reply). The evaluation script does **not** call the Sigil model; it only runs the metrics on existing `actual_output` values. + +Example data point: + +```json +{ + "input": "What is a pointer?", + "output": "A pointer is a variable that holds a memory address.", + "actual_output": "A pointer is a variable that stores the address of another variable...", + "code": "" +} +``` + +## Creating metrics + +### Example metric JSON ```json { - "name": "Example Metric", + "name": "Tutor Similarity", "config": { - "needs_history": false, + "needs_history": true, "needs_example_output": true }, - "metric_description": "How correct the output is", + "metric_description": "How well the chatbot responds compared to the example tutor response. Consider correctness, tone, and depth.", "score": { "type": "scale", - "description": "1 is completely incorrect, 5 is completely correct", + "description": "1 = poor, 5 = excellent", "min": 1, "max": 5 } @@ -63,46 +101,44 @@ We have built our own evaluation module for Sigil. It allows one to define reusa ### Fields -- `name` - a descriptive name for the metric -- `config` - an object with some useful metadata - - `needs_history` - whether or not the conversation history should be considered in the metric. If this is enabled, the dataset will be treated like a whole conversation rather than individual inputs and outputs - - `needs_example_output` - whether or not it needs example output to compare to. If this is enabled, it will expect there to be outputs in the dataset -- `metric_description` - A description of the metric* -- `score` - an object representing what kind of output the metric should give - - `type` - can be any of the following: - - `"scale"` - an integer scale for the score (e.g. 1-5), there must also be a `"min"` and `"max"` field, where min < max - - `"boolean"` - true or false. No extra metadata needed - - `"percentage"` - a float percentage (0-100). No extra metadata needed - - `description` - A description of what the score represents* +- `name` – Descriptive name for the metric. +- `config` – Optional metadata (`needs_history`, `needs_example_output`, etc.). +- `metric_description` – Description used by the evaluator LLM; be specific so scores are consistent. +- `score` – Optional; defines score type: + - `"scale"` – Integer scale; include `min` and `max`. + - `"boolean"` – True/false. + - `"percentage"` – Float 0–100. -*Make sure these fields are descriptive enough, as they will affect the behavior of the evaluator LLM +The implementation uses **DeepEval’s GEval**; `OPENAI_API_KEY` is required. -## Creating Tests +## End-to-end example -### Example JSON File +1. Ensure you have a **result** dataset (with `input`, `output`, `actual_output`, and optionally `code` for each item). For example, `test/test_cases/cs1qa_small_results_v1-0.json`. +2. Ensure you have at least one metric file, e.g. `test/metrics/similarity.json`. +3. Create a test config, e.g. `test/tests/my_test.json`: -```json -{ - "datasets": ["./data/dataset1.json", "./data/dataset2.json"], - "metrics": ["./metrics/metric1.json", "./metrics/metric2.json"] -} -``` + ```json + { + "datasets": "test_cases/cs1qa_small_results_v1-0.json", + "metrics": ["metrics/similarity.json"] + } + ``` -### Fields - -- `datasets` - an array of paths to JSON files containing the datasets to be used in the test -- `metrics` - an array of paths to JSON files containing the metrics to be used in the test +4. From the repo root, set `OPENAI_API_KEY` and run: -Every dataset will be evaluated with every metric. Relative paths will depend on the directory from which you run the evaluation program. + ```bash + cd sigil-ps-core/test + python evaluation.py tests/my_test.json results/my_output.json + ``` -## Run Evaluation +5. Open `results/my_output.json` for per-item scores and overall metric averages. -Once you have set up all of these JSON files, you can run the evaluation program. Simply run `evaluation.py` and provide your test JSON file as an argument. Remember that relative paths will depend on where you run the program. For simplicity, assume that you will be running it from the directory where the `evaluation.py` script is located in the project, or define some other convention. +## Troubleshooting -```bash -python evaluation.py /path/to/test.json -``` +- **Missing keys:** If you see `KeyError` or similar, check that every data point has `input`, `output`, and `actual_output`; the test config has `datasets` (string) and `metrics` (array). +- **Path errors:** All paths in the test config are relative to the **current working directory**. Run from `test/` and use paths like `test_cases/...` and `metrics/...`. +- **API key:** Ensure `OPENAI_API_KEY` is set in the environment where you run `evaluation.py`; the GEval model needs it. -The output should look something like this: +The output structure is similar to: -![alt text](image.png) \ No newline at end of file +![alt text](image.png) diff --git a/ui/.env.template b/ui/.env.template index 7000834..8cc5d8e 100644 --- a/ui/.env.template +++ b/ui/.env.template @@ -1 +1,2 @@ -VITE_API_BASE= \ No newline at end of file +VITE_API_BASE= +# test \ No newline at end of file diff --git a/ui/README.md b/ui/README.md index 40ede56..4d6f182 100644 --- a/ui/README.md +++ b/ui/README.md @@ -1,54 +1,49 @@ -# React + TypeScript + Vite - -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. - -Currently, two official plugins are available: - -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh - -## Expanding the ESLint configuration - -If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: - -```js -export default tseslint.config({ - extends: [ - // Remove ...tseslint.configs.recommended and replace with this - ...tseslint.configs.recommendedTypeChecked, - // Alternatively, use this for stricter rules - ...tseslint.configs.strictTypeChecked, - // Optionally, add this for stylistic rules - ...tseslint.configs.stylisticTypeChecked, - ], - languageOptions: { - // other options... - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - }, -}) -``` +# SIGIL-PS Chat UI + +React (Vite) chat interface for Sigil. It is used in two ways: + +1. **Served by the core API** – The Flask app in sigil-ps-core serves the built app from `ui/dist` at `/` in production. +2. **Embedded in the VS Code extension** – The extension loads a built version of this UI in a webview (via bundled assets in the extension’s `media/` folder). + +## Stack + +- **React 19**, **TypeScript**, **Vite 6** +- **Tailwind CSS** for styling +- **Axios** for API calls; **TanStack React Query** for data/state +- Key modules: `ChatApp.tsx`, `useChat.ts` (hooks), `vscodeApi.ts` (when running inside the VS Code webview) -You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: - -```js -// eslint.config.js -import reactX from 'eslint-plugin-react-x' -import reactDom from 'eslint-plugin-react-dom' - -export default tseslint.config({ - plugins: { - // Add the react-x and react-dom plugins - 'react-x': reactX, - 'react-dom': reactDom, - }, - rules: { - // other rules... - // Enable its recommended typescript rules - ...reactX.configs['recommended-typescript'].rules, - ...reactDom.configs.recommended.rules, - }, -}) +## Setup + +From the `ui/` directory: + +```bash +pnpm install ``` + +(or `npm install` if you use npm; the repo includes `pnpm-lock.yaml`.) + +## Commands + +| Command | Description | +|----------------|-------------| +| `pnpm run dev` | Start the Vite dev server (e.g. http://localhost:5173). | +| `pnpm run build` | Type-check and build for production; output goes to `dist/`. | +| `pnpm run preview` | Serve the built `dist/` app (e.g. port 5173). | +| `pnpm run lint` | Run ESLint. | + +## Environment + +If you run the UI in dev and need to point at a specific API, use `ui/.env.template` as a reference. Copy it to `.env` and set: + +- `VITE_API_BASE` – Base URL of the Sigil API (e.g. `http://localhost:80` or `http://localhost:5000`). Leave empty if the app gets the API URL from the extension (webview) or from the same origin. + +Paths and behavior may depend on how the extension or API serves the app. + +## Integration + +- **Core API:** After `pnpm run build`, the contents of `dist/` are served by the Flask app as static files at `/`. The API is configured to serve `index.html` for client-side routing (see sigil-ps-core `api/__init__.py`). +- **VS Code extension:** The extension builds or copies this UI and exposes it in a webview. The webview uses `vscodeApi.ts` to talk to the extension host; the extension then calls the Sigil API. API base URL is determined by the extension (e.g. `apiConfig.ts` in sigil-ps-vscode). + +## Testing + +There are no automated UI tests (e.g. Vitest or Playwright) in this repo at the moment. Manual testing: run `pnpm run dev`, point the app at your local API if needed, and exercise the chat flow. diff --git a/ui/src/ChatApp.tsx b/ui/src/ChatApp.tsx index 153486e..8f1755f 100644 --- a/ui/src/ChatApp.tsx +++ b/ui/src/ChatApp.tsx @@ -14,7 +14,10 @@ export function ChatApp() { submitFeedback, requestAuth, getFeedbackStatus, - getPendingFeedback + getPendingFeedback, + attachments, + pickFiles, + removeAttachment } = useChat(); const messagesEndRef = useRef(null); @@ -60,8 +63,17 @@ export function ChatApp() { {/* Error Display */} {error && ( -
-

{error}

+
+
+ +

{error}

+
)} @@ -89,6 +101,9 @@ export function ChatApp() { onSend={(msg, attachments) => sendMessage(msg, true, attachments)} disabled={loading} fileContext={fileContext} + attachments={attachments} + pickFiles={pickFiles} + removeAttachment={removeAttachment} />
); diff --git a/ui/src/components/MessageInput.tsx b/ui/src/components/MessageInput.tsx index 7fecbcc..9dd6b3e 100644 --- a/ui/src/components/MessageInput.tsx +++ b/ui/src/components/MessageInput.tsx @@ -1,5 +1,6 @@ -import { useState, KeyboardEvent, useRef } from 'react'; -import { FileContext } from '../hooks/useChat'; +import { useState, KeyboardEvent, useEffect, useRef } from 'react'; +import { FileContext, Attachment } from '../hooks/useChat'; +import { vscodeApi, setupMessageListener, VSCodeMessage } from '../utils/vscodeApi'; function PaperclipIcon() { return ( @@ -9,53 +10,111 @@ function PaperclipIcon() { ); } -interface Attachment { - fileName: string; - content: string; -} - interface MessageInputProps { onSend: (message: string, attachments: Attachment[]) => void; disabled?: boolean; placeholder?: string; fileContext?: FileContext | null; + attachments: Attachment[]; + pickFiles: () => void; + removeAttachment: (fileName: string) => void; } -export function MessageInput({ onSend, disabled = false, placeholder = "Type your message...", fileContext }: MessageInputProps) { +export function MessageInput({ onSend, disabled = false, placeholder = "Type your message...", fileContext, attachments, pickFiles, removeAttachment }: MessageInputProps) { const [message, setMessage] = useState(''); - const [attachments, setAttachments] = useState([]); - const fileInputRef = useRef(null); + const [contextPickerActive, setContextPickerActive] = useState(false); + const textareaRef = useRef(null); + const hashPositionRef = useRef(-1); + + useEffect(() => { + const handleMessage = (msg: VSCodeMessage) => { + if (msg.command === 'contextPickerResult') { + setContextPickerActive(false); + + if (msg.result && textareaRef.current) { + // Insert the reference into the input at the hash position + const textarea = textareaRef.current; + const currentValue = textarea.value; + const cursorPos = textarea.selectionStart; + + // Find the hash position + if (hashPositionRef.current >= 0) { + const beforeHash = currentValue.substring(0, hashPositionRef.current); + const afterCursor = currentValue.substring(cursorPos); + const reference = msg.result.reference || `#${msg.result.file || msg.result.name}`; + + const newValue = beforeHash + reference + ' ' + afterCursor; + setMessage(newValue); + + // Set cursor position after the inserted reference + setTimeout(() => { + const newCursorPos = hashPositionRef.current + reference.length + 1; + textarea.setSelectionRange(newCursorPos, newCursorPos); + }, 0); + } + + hashPositionRef.current = -1; + } + } + }; + setupMessageListener(handleMessage); + }, []); const handleSend = () => { if (message.trim() && !disabled) { onSend(message, attachments); setMessage(''); - setAttachments([]); + // Don't clear attachments - they should persist for visibility } }; const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Enter' && !e.shiftKey) { + if (e.key === 'Enter' && !e.shiftKey && !contextPickerActive) { e.preventDefault(); handleSend(); } }; - const handleFileSelect = async (files: FileList | null) => { - if (!files) return; - const newAttachments: Attachment[] = []; - for (const file of Array.from(files)) { - if (file.size > 1024 * 1024) continue; - const text = await file.text(); - newAttachments.push({ fileName: file.name, content: text }); - } - if (newAttachments.length) { - setAttachments(prev => [...prev, ...newAttachments]); + const handleInputChange = (e: React.ChangeEvent) => { + const value = e.target.value; + const cursorPos = e.target.selectionStart; + + // Check if "#" was just typed + if (value.length > 0 && cursorPos > 0 && value[cursorPos - 1] === '#') { + // Store the hash position + hashPositionRef.current = cursorPos - 1; + + // Extract query after "#" (if any) + const textAfterHash = value.substring(cursorPos); + const spaceIndex = textAfterHash.indexOf(' '); + const query = spaceIndex > 0 ? textAfterHash.substring(0, spaceIndex) : ''; + + // Trigger context picker + setContextPickerActive(true); + vscodeApi.postMessage({ + command: 'openContextPicker', + query: query + }); + } else if (contextPickerActive && value.length > 0) { + // Update query as user types after "#" + const hashIndex = value.lastIndexOf('#', cursorPos - 1); + if (hashIndex >= 0) { + const textAfterHash = value.substring(hashIndex + 1, cursorPos); + const spaceIndex = textAfterHash.indexOf(' '); + const query = spaceIndex > 0 ? textAfterHash.substring(0, spaceIndex) : textAfterHash; + + // Update context picker query + vscodeApi.postMessage({ + command: 'openContextPicker', + query: query + }); + } + } else { + setContextPickerActive(false); + hashPositionRef.current = -1; } - }; - - const removeAttachment = (name: string) => { - setAttachments(prev => prev.filter(a => a.fileName !== name)); + + setMessage(value); }; return ( @@ -73,10 +132,35 @@ export function MessageInput({ onSend, disabled = false, placeholder = "Type you )} + {/* Attachments display above input area */} + {attachments.length > 0 && ( +
+ {attachments.map(att => ( + + {att.fileName} + + + ))} +
+ )}
- handleFileSelect(e.target.files)} - /> - {attachments.length > 0 && ( -
- {attachments.map(att => ( - - {att.fileName} - - - ))} -
- )}