From 2f386205f48ac77252983494032af4a08b45ffab Mon Sep 17 00:00:00 2001 From: artdeone Date: Mon, 15 Jun 2026 10:24:38 +0630 Subject: [PATCH] Initial release v1.0.0 --- .gitignore | 4 + README.md | 123 ++++++ main.js | 1053 +++++++++++++++++++++++++++++++++++++++++++++++++ manifest.json | 10 + styles.css | 494 +++++++++++++++++++++++ 5 files changed, 1684 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 main.js create mode 100644 manifest.json create mode 100644 styles.css diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..db55c1e --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +*.bak +data.json +.DS_Store \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..500f9e7 --- /dev/null +++ b/README.md @@ -0,0 +1,123 @@ +# ART de ONE + +Highlight text, build tables, and ask AI — all inside Obsidian. + +--- + +## Features + +### ✦ AI Assistant +Select any text, right-click, and choose **Ask ART de ONE** to open the AI panel. Ask questions, get explanations, and apply the response back to your note — in full or as a partial selection. + +- Works with any OpenAI-compatible API endpoint +- Streams responses in real time +- Switch models on the fly from the modal header +- Apply the full response or select a portion to apply only that part +- Retry, Copy, or Cancel at any time + +### ⬛ Highlight +Select text and press `Cmd+Shift+H` (Mac) or `Ctrl+Shift+H` (Windows/Linux) to highlight in one of six colors. Click a color swatch to apply instantly. Click the same color again to remove the highlight. + +**Available colors:** Yellow · Green · Blue · Pink · Orange · Purple + +### ⊞ Table Tools +Insert tables with a visual grid picker, and edit them with a floating toolbar that appears whenever your cursor is inside a table. + +**Toolbar actions:** +- Add row below +- Add column right +- Delete row +- Delete column +- Format / align table + +All table edits support **Undo** (`Cmd+Z`) and **Redo** (`Cmd+Shift+Z`). + +--- + +## Installation + +### Manual + +1. Download `main.js`, `manifest.json`, and `styles.css` from the [latest release](../../releases/latest) +2. In your vault, open `.obsidian/plugins/` +3. Create a new folder named `art-de-one` +4. Copy the three files into that folder +5. In Obsidian → Settings → Community Plugins → disable **Restricted Mode** +6. Enable **ART de ONE** in the installed plugins list + +### Community Plugin Directory + +Search for **ART de ONE** in Obsidian → Settings → Community Plugins → Browse. + +--- + +## Setup + +### AI Configuration + +1. Go to **Settings → ART de ONE** +2. Enter your **API Base URL** (e.g. `https://api.openai.com/v1`) +3. Enter your **API Key** +4. Enter a **Default Model** (e.g. `gpt-4o-mini`, `claude-sonnet-4-6`) +5. Click **Test Connection** to verify +6. Optionally add more models to the **Saved Models** list — these appear in the modal dropdown + +Any OpenAI-compatible endpoint works, including local models via Ollama, LM Studio, or similar. + +--- + +## Usage + +| Feature | How to trigger | +|---|---| +| Ask AI | Select text → Right-click → **Ask ART de ONE** | +| Ask AI (no selection) | Click the ART de ONE icon in the left sidebar | +| Highlight | Select text → `Cmd/Ctrl+Shift+H` → pick a color | +| Insert Table | Sidebar icon → **Insert Table**, or Command Palette | +| Table toolbar | Place cursor inside any Markdown table | +| Undo table edit | `Cmd/Ctrl+Z` | + +--- + +## Keyboard Shortcuts + +| Shortcut | Action | +|---|---| +| `Cmd+Shift+H` / `Ctrl+Shift+H` | Highlight selected text | +| `Escape` | Close AI modal without applying | + +--- + +## Settings Reference + +| Setting | Description | Default | +|---|---|---| +| API Base URL | Your OpenAI-compatible endpoint | `https://api.openai.com/v1` | +| API Key | Your API key (stored locally) | — | +| Default Model | Model used when the plugin opens | `gpt-4o-mini` | +| Saved Models | List of models for the dropdown | — | +| System Prompt | Instruction sent to the AI on every request | Study assistant prompt | +| Max Tokens | Maximum response length | `1024` | +| Temperature | Creativity level (0 = precise, 2 = creative) | `0.7` | +| Stream responses | Show response as it generates | On | +| Default highlight color | Color pre-selected when picker opens | Yellow | + +--- + +## Privacy + +- API keys are stored locally in your vault's `.obsidian/plugins/art-de-one/data.json` file and are never sent anywhere except your configured API endpoint. +- No analytics, no telemetry, no external connections other than your own AI API calls. +- All highlight and table data stays in your Markdown files. + +--- + +## License + +MIT License. Copyright (c) 2026 ART de ONE. + +--- + +## Author + +Built by [ART de ONE](https://github.com/artdeone) — a digital creative brand based in Myanmar. diff --git a/main.js b/main.js new file mode 100644 index 0000000..ac9688b --- /dev/null +++ b/main.js @@ -0,0 +1,1053 @@ +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); + +// main.ts +var main_exports = {}; +__export(main_exports, { + default: () => CourseCompanionPlugin +}); +module.exports = __toCommonJS(main_exports); +var import_obsidian = require("obsidian"); +var ICONS = { + highlight: ``, + table: ``, + "table-row": ``, + "table-col": ``, + "table-del-row": ``, + "table-del-col": ``, + "ai-spark": ``, + send: ``, + copy: ``, + apply: ``, + cancel: ``, + retry: ``, + model: ``, + settings: `` +}; +function setIcon(el, iconName) { + el.innerHTML = ICONS[iconName]; +} +var HIGHLIGHT_COLORS = [ + { name: "yellow", value: "#FFF176" }, + { name: "green", value: "#B9F6CA" }, + { name: "blue", value: "#B3E5FC" }, + { name: "pink", value: "#F8BBD0" }, + { name: "orange", value: "#FFE0B2" }, + { name: "purple", value: "#E1BEE7" } +]; +var DEFAULT_SETTINGS = { + apiBaseURL: "https://api.openai.com/v1", + apiKey: "", + defaultModel: "gpt-4o-mini", + savedModels: ["gpt-4o-mini", "gpt-4o"], + systemPrompt: "You are a helpful study assistant. Answer clearly and concisely.", + maxTokens: 1024, + temperature: 0.7, + streamResponses: true, + lastHighlightColor: "#FFF176", + defaultHighlightColor: "#FFF176" +}; +var CourseCompanionPlugin = class extends import_obsidian.Plugin { + constructor() { + super(...arguments); + this.settings = DEFAULT_SETTINGS; + this.highlightPopover = null; + this.tableToolbar = null; + } + async onload() { + await this.loadSettings(); + this.addSettingTab(new CourseCompanionSettingTab(this.app, this)); + this.addCommand({ + id: "highlight-selection", + name: "Highlight selection", + hotkeys: [{ modifiers: ["Mod", "Shift"], key: "h" }], + editorCallback: (editor) => { + const view = this.app.workspace.getActiveViewOfType(import_obsidian.MarkdownView); + if (view) + this.openHighlightPicker(editor, view); + } + }); + this.addCommand({ + id: "insert-table", + name: "Table: Insert table", + editorCallback: (editor) => { + new TableGridPicker(this.app, (rows, cols) => { + this.insertTable(editor, rows, cols); + }).open(); + } + }); + this.addCommand({ + id: "add-row-below", + name: "Table: Add row below", + editorCallback: (editor) => this.tableAddRowBelow(editor) + }); + this.addCommand({ + id: "add-col-right", + name: "Table: Add column right", + editorCallback: (editor) => this.tableAddColRight(editor) + }); + this.addCommand({ + id: "delete-row", + name: "Table: Delete row", + editorCallback: (editor) => this.tableDeleteRow(editor) + }); + this.addCommand({ + id: "delete-col", + name: "Table: Delete column", + editorCallback: (editor) => this.tableDeleteCol(editor) + }); + this.addCommand({ + id: "format-table", + name: "Table: Format (align pipes)", + editorCallback: (editor) => this.tableFormat(editor) + }); + this.addCommand({ + id: "ask-ai", + name: "Ask AI about selection", + editorCallback: (editor) => { + const selection = editor.getSelection(); + new AIModal(this.app, this, selection, (replacement) => { + if (replacement) + editor.replaceSelection(replacement); + }).open(); + } + }); + this.registerEvent( + this.app.workspace.on("editor-menu", (menu, editor) => { + const sel = editor.getSelection(); + if (sel && sel.trim().length > 0) { + menu.addItem((item) => { + item.setTitle("Ask AI").setIcon("sparkles").onClick(() => { + new AIModal(this.app, this, sel, (replacement) => { + if (replacement) + editor.replaceSelection(replacement); + }).open(); + }); + }); + } + }) + ); + this.registerEvent( + this.app.workspace.on("active-leaf-change", () => this.updateTableToolbar()) + ); + this.registerEvent( + this.app.workspace.on("editor-change", () => this.updateTableToolbar()) + ); + this.registerDomEvent(document, "selectionchange", () => { + window.setTimeout(() => this.updateTableToolbar(), 30); + }); + this.registerDomEvent(document, "mousedown", (evt) => { + if (this.highlightPopover && !this.highlightPopover.containsTarget(evt.target)) { + this.closeHighlightPopover(); + } + }); + } + onunload() { + this.closeHighlightPopover(); + if (this.tableToolbar) { + this.tableToolbar.destroy(); + this.tableToolbar = null; + } + } + async loadSettings() { + const data = await this.loadData(); + this.settings = Object.assign({}, DEFAULT_SETTINGS, data != null ? data : {}); + } + async saveSettings() { + await this.saveData(this.settings); + } + openHighlightPicker(editor, view) { + this.closeHighlightPopover(); + const sel = editor.getSelection(); + if (!sel) { + new import_obsidian.Notice("Select some text first"); + return; + } + const stripRe = /^([\s\S]*)<\/mark>$/; + const m = sel.match(stripRe); + if (m) { + editor.replaceSelection(m[1]); + return; + } + const popover = new HighlightPopover( + view.contentEl, + this.settings.lastHighlightColor, + async (color) => { + const currentSel = editor.getSelection() || sel; + editor.replaceSelection( + `${currentSel}` + ); + this.settings.lastHighlightColor = color; + await this.saveSettings(); + this.closeHighlightPopover(); + } + ); + popover.show(); + this.highlightPopover = popover; + } + closeHighlightPopover() { + if (this.highlightPopover) { + this.highlightPopover.destroy(); + this.highlightPopover = null; + } + } + isTableRow(line) { + return /^\s*\|.*\|\s*$/.test(line); + } + isSeparatorRow(line) { + return /^\s*\|?\s*:?-{3,}.*\|/.test(line) && /-/.test(line); + } + findTableBounds(editor, lineNo) { + const totalLines = editor.lineCount(); + if (!this.isTableRow(editor.getLine(lineNo))) + return null; + let start = lineNo; + let end = lineNo; + while (start > 0 && this.isTableRow(editor.getLine(start - 1))) + start--; + while (end < totalLines - 1 && this.isTableRow(editor.getLine(end + 1))) + end++; + return { start, end }; + } + parseTableLine(line) { + let l = line.trim(); + if (l.startsWith("|")) + l = l.slice(1); + if (l.endsWith("|")) + l = l.slice(0, -1); + return l.split("|").map((c) => c.trim()); + } + buildTableLine(cells) { + return "| " + cells.join(" | ") + " |"; + } + insertTable(editor, rows, cols) { + const header = this.buildTableLine(new Array(cols).fill("Header")); + const sep = "| " + new Array(cols).fill("---").join(" | ") + " |"; + const body = []; + for (let r = 0; r < rows; r++) { + body.push(this.buildTableLine(new Array(cols).fill(" "))); + } + const tableStr = [header, sep, ...body].join("\n") + "\n"; + editor.replaceSelection(tableStr); + } + tableAddRowBelow(editor) { + const cur = editor.getCursor(); + const bounds = this.findTableBounds(editor, cur.line); + if (!bounds) { + new import_obsidian.Notice("Cursor is not inside a table"); + return; + } + const headerCells = this.parseTableLine(editor.getLine(bounds.start)); + const newRow = this.buildTableLine(new Array(headerCells.length).fill(" ")); + const insertAfter = cur.line < bounds.start + 2 ? bounds.start + 1 : cur.line; + editor.replaceRange(newRow + "\n", { line: insertAfter + 1, ch: 0 }); + } + tableAddColRight(editor) { + const cur = editor.getCursor(); + const bounds = this.findTableBounds(editor, cur.line); + if (!bounds) { + new import_obsidian.Notice("Cursor is not inside a table"); + return; + } + const line = editor.getLine(cur.line); + const colIdx = this.colIndexAt(line, cur.ch); + const newLines = []; + for (let i = bounds.start; i <= bounds.end; i++) { + const raw = editor.getLine(i); + if (this.isSeparatorRow(raw)) { + const cells = this.parseTableLine(raw); + cells.splice(colIdx + 1, 0, "---"); + newLines.push("| " + cells.join(" | ") + " |"); + } else { + const cells = this.parseTableLine(raw); + cells.splice(colIdx + 1, 0, " "); + newLines.push(this.buildTableLine(cells)); + } + } + editor.replaceRange( + newLines.join("\n"), + { line: bounds.start, ch: 0 }, + { line: bounds.end, ch: editor.getLine(bounds.end).length } + ); + } + tableDeleteRow(editor) { + const cur = editor.getCursor(); + const bounds = this.findTableBounds(editor, cur.line); + if (!bounds) { + new import_obsidian.Notice("Cursor is not inside a table"); + return; + } + if (cur.line === bounds.start || cur.line === bounds.start + 1) { + new import_obsidian.Notice("Cannot delete header or separator"); + return; + } + editor.replaceRange( + "", + { line: cur.line, ch: 0 }, + { line: cur.line + 1, ch: 0 } + ); + } + tableDeleteCol(editor) { + const cur = editor.getCursor(); + const bounds = this.findTableBounds(editor, cur.line); + if (!bounds) { + new import_obsidian.Notice("Cursor is not inside a table"); + return; + } + const line = editor.getLine(cur.line); + const colIdx = this.colIndexAt(line, cur.ch); + const newLines = []; + for (let i = bounds.start; i <= bounds.end; i++) { + const raw = editor.getLine(i); + const cells = this.parseTableLine(raw); + if (cells.length <= 1) { + newLines.push(raw); + continue; + } + cells.splice(colIdx, 1); + if (this.isSeparatorRow(raw)) { + newLines.push("| " + cells.join(" | ") + " |"); + } else { + newLines.push(this.buildTableLine(cells)); + } + } + editor.replaceRange( + newLines.join("\n"), + { line: bounds.start, ch: 0 }, + { line: bounds.end, ch: editor.getLine(bounds.end).length } + ); + } + tableFormat(editor) { + const cur = editor.getCursor(); + const bounds = this.findTableBounds(editor, cur.line); + if (!bounds) { + new import_obsidian.Notice("Cursor is not inside a table"); + return; + } + const rows = []; + const sepIdx = []; + for (let i = bounds.start; i <= bounds.end; i++) { + const raw = editor.getLine(i); + rows.push(this.parseTableLine(raw)); + if (this.isSeparatorRow(raw)) + sepIdx.push(i - bounds.start); + } + const colCount = Math.max(...rows.map((r) => r.length)); + const widths = new Array(colCount).fill(0); + rows.forEach((r, ri) => { + var _a; + if (sepIdx.includes(ri)) + return; + for (let c = 0; c < colCount; c++) { + const cell = (_a = r[c]) != null ? _a : ""; + if (cell.length > widths[c]) + widths[c] = cell.length; + } + }); + const formatted = rows.map((r, ri) => { + if (sepIdx.includes(ri)) { + const cells2 = new Array(colCount).fill(0).map((_, c) => "-".repeat(Math.max(3, widths[c]))); + return "| " + cells2.join(" | ") + " |"; + } + const cells = new Array(colCount).fill(0).map((_, c) => { + var _a; + const v = (_a = r[c]) != null ? _a : ""; + return v + " ".repeat(widths[c] - v.length); + }); + return "| " + cells.join(" | ") + " |"; + }); + editor.replaceRange( + formatted.join("\n"), + { line: bounds.start, ch: 0 }, + { line: bounds.end, ch: editor.getLine(bounds.end).length } + ); + } + colIndexAt(line, ch) { + let count = 0; + for (let i = 0; i < Math.min(ch, line.length); i++) { + if (line[i] === "|") + count++; + } + return Math.max(0, count - 1); + } + updateTableToolbar() { + const view = this.app.workspace.getActiveViewOfType(import_obsidian.MarkdownView); + if (!view) { + if (this.tableToolbar) + this.tableToolbar.hide(); + return; + } + const editor = view.editor; + const cur = editor.getCursor(); + const line = editor.getLine(cur.line); + if (!line || !this.isTableRow(line)) { + if (this.tableToolbar) + this.tableToolbar.hide(); + return; + } + if (!this.tableToolbar) { + this.tableToolbar = new TableToolbar(view.contentEl, this); + } + this.tableToolbar.showNear(view, editor, cur); + } +}; +var HighlightPopover = class { + constructor(parent, currentColor, onPick) { + this.parent = parent; + this.el = document.createElement("div"); + this.el.className = "cc-highlight-popover"; + HIGHLIGHT_COLORS.forEach((c) => { + const sw = document.createElement("button"); + sw.className = "cc-swatch"; + sw.style.background = c.value; + sw.setAttribute("aria-label", c.name); + if (c.value.toLowerCase() === currentColor.toLowerCase()) { + sw.classList.add("cc-selected"); + } + sw.addEventListener("click", (e) => { + e.preventDefault(); + e.stopPropagation(); + onPick(c.value); + }); + this.el.appendChild(sw); + }); + } + show() { + this.parent.appendChild(this.el); + const sel = window.getSelection(); + if (sel && sel.rangeCount > 0) { + const rect = sel.getRangeAt(0).getBoundingClientRect(); + const parentRect = this.parent.getBoundingClientRect(); + this.el.style.left = `${rect.left - parentRect.left}px`; + this.el.style.top = `${rect.bottom - parentRect.top + 4}px`; + } else { + this.el.style.left = "50%"; + this.el.style.top = "40%"; + } + requestAnimationFrame(() => this.el.classList.add("cc-visible")); + } + containsTarget(target) { + if (!target) + return false; + return this.el.contains(target); + } + destroy() { + this.el.remove(); + } +}; +var TableGridPicker = class extends import_obsidian.Modal { + constructor(app, onPick) { + super(app); + this.rows = 0; + this.cols = 0; + this.onPick = onPick; + } + onOpen() { + const { contentEl, modalEl } = this; + modalEl.addClass("cc-modal"); + contentEl.empty(); + contentEl.addClass("cc-grid-picker"); + const title = contentEl.createDiv({ cls: "cc-grid-title" }); + title.setText("Insert table"); + const label = contentEl.createDiv({ cls: "cc-grid-label" }); + label.setText("0 \xD7 0"); + const grid = contentEl.createDiv({ cls: "cc-grid" }); + const cells = []; + const SIZE = 8; + for (let r = 0; r < SIZE; r++) { + const row = []; + for (let c = 0; c < SIZE; c++) { + const cell = grid.createDiv({ cls: "cc-grid-cell" }); + cell.dataset.row = String(r); + cell.dataset.col = String(c); + cell.addEventListener("mouseenter", () => { + this.rows = r + 1; + this.cols = c + 1; + label.setText(`${this.rows} \xD7 ${this.cols}`); + for (let rr = 0; rr < SIZE; rr++) { + for (let cc = 0; cc < SIZE; cc++) { + cells[rr][cc].toggleClass( + "cc-grid-active", + rr <= r && cc <= c + ); + } + } + }); + cell.addEventListener("click", () => { + if (this.rows > 0 && this.cols > 0) { + this.onPick(this.rows, this.cols); + this.close(); + } + }); + row.push(cell); + } + cells.push(row); + } + } + onClose() { + this.contentEl.empty(); + } +}; +var TableToolbar = class { + constructor(parent, plugin) { + this.visible = false; + this.parent = parent; + this.plugin = plugin; + this.el = document.createElement("div"); + this.el.className = "cc-table-toolbar"; + this.buildButtons(); + this.parent.appendChild(this.el); + } + buildButtons() { + const mkBtn = (icon, label, action) => { + const b = document.createElement("button"); + b.className = "cc-tb-btn"; + b.setAttribute("aria-label", label); + b.title = label; + setIcon(b, icon); + b.addEventListener("mousedown", (e) => { + e.preventDefault(); + e.stopPropagation(); + action(); + }); + return b; + }; + const getEditor = () => { + const v = this.plugin.app.workspace.getActiveViewOfType(import_obsidian.MarkdownView); + return v ? v.editor : null; + }; + this.el.appendChild( + mkBtn("table-row", "Add row below", () => { + const e = getEditor(); + if (e) + this.plugin.tableAddRowBelow(e); + }) + ); + this.el.appendChild( + mkBtn("table-col", "Add column right", () => { + const e = getEditor(); + if (e) + this.plugin.tableAddColRight(e); + }) + ); + const divider = document.createElement("div"); + divider.className = "cc-tb-divider"; + this.el.appendChild(divider); + this.el.appendChild( + mkBtn("table-del-row", "Delete row", () => { + const e = getEditor(); + if (e) + this.plugin.tableDeleteRow(e); + }) + ); + this.el.appendChild( + mkBtn("table-del-col", "Delete column", () => { + const e = getEditor(); + if (e) + this.plugin.tableDeleteCol(e); + }) + ); + const divider2 = document.createElement("div"); + divider2.className = "cc-tb-divider"; + this.el.appendChild(divider2); + this.el.appendChild( + mkBtn("table", "Format table", () => { + const e = getEditor(); + if (e) + this.plugin.tableFormat(e); + }) + ); + } + showNear(view, editor, cursor) { + try { + const cm = editor.cm; + const coords = (cm == null ? void 0 : cm.coordsAtPos) ? cm.coordsAtPos(editor.posToOffset(cursor)) : null; + const parentRect = this.parent.getBoundingClientRect(); + let top = 60; + let left = 60; + if (coords) { + top = coords.top - parentRect.top - 44; + left = coords.left - parentRect.left; + if (top < 4) + top = coords.bottom - parentRect.top + 8; + } + this.el.style.top = `${Math.max(4, top)}px`; + this.el.style.left = `${Math.max(4, left)}px`; + } catch (e) { + } + if (!this.visible) { + this.el.classList.add("cc-visible"); + this.visible = true; + } + } + hide() { + if (this.visible) { + this.el.classList.remove("cc-visible"); + this.visible = false; + } + } + destroy() { + this.el.remove(); + } +}; +var AIModal = class extends import_obsidian.Modal { + constructor(app, plugin, selectedText, onApply) { + super(app); + this.abortController = null; + this.responseText = ""; + this.lastQuestion = ""; + this.plugin = plugin; + this.selectedText = selectedText; + this.onApply = onApply; + this.currentModel = plugin.settings.defaultModel; + } + onOpen() { + const { contentEl, modalEl } = this; + modalEl.addClass("cc-modal"); + modalEl.addClass("cc-ai-modal"); + contentEl.empty(); + const header = contentEl.createDiv({ cls: "cc-ai-header" }); + const titleWrap = header.createDiv({ cls: "cc-ai-title" }); + const sparkEl = titleWrap.createSpan({ cls: "cc-ai-icon" }); + setIcon(sparkEl, "ai-spark"); + titleWrap.createSpan({ text: "Course Companion AI" }); + const modelWrap = header.createDiv({ cls: "cc-model-wrap" }); + const modelIcon = modelWrap.createSpan({ cls: "cc-model-icon" }); + setIcon(modelIcon, "model"); + this.modelSelect = modelWrap.createEl("select", { cls: "cc-model-select" }); + this.populateModels(); + this.modelSelect.addEventListener("change", () => { + this.currentModel = this.modelSelect.value; + }); + this.errorBanner = contentEl.createDiv({ cls: "cc-error-banner cc-hidden" }); + if (this.selectedText && this.selectedText.trim().length > 0) { + const ctxLabel = contentEl.createDiv({ cls: "cc-section-label" }); + ctxLabel.setText("Selected text"); + const ctxBox = contentEl.createDiv({ cls: "cc-context-box" }); + ctxBox.setText(this.selectedText); + } + const qLabel = contentEl.createDiv({ cls: "cc-section-label" }); + qLabel.setText("Your question"); + const inputRow = contentEl.createDiv({ cls: "cc-input-row" }); + this.inputEl = inputRow.createEl("textarea", { cls: "cc-input" }); + this.inputEl.rows = 2; + this.inputEl.placeholder = "Ask anything about the selected text..."; + if (this.selectedText && this.selectedText.trim().length > 0) { + this.inputEl.value = `Explain this: ${this.selectedText.substring(0, 200)}`; + } + this.inputEl.addEventListener("keydown", (e) => { + if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { + e.preventDefault(); + void this.send(); + } + }); + this.sendBtn = inputRow.createEl("button", { cls: "cc-btn cc-btn-primary cc-send-btn" }); + const sendIc = this.sendBtn.createSpan(); + setIcon(sendIc, "send"); + this.sendBtn.createSpan({ text: " Send" }); + this.sendBtn.addEventListener("click", () => void this.send()); + const respLabel = contentEl.createDiv({ cls: "cc-section-label" }); + respLabel.setText("Response"); + this.responseEl = contentEl.createDiv({ cls: "cc-response" }); + this.responseEl.setText(""); + const footer = contentEl.createDiv({ cls: "cc-ai-footer" }); + const leftFooter = footer.createDiv({ cls: "cc-footer-left" }); + const rightFooter = footer.createDiv({ cls: "cc-footer-right" }); + const cancelBtn = leftFooter.createEl("button", { cls: "cc-btn cc-btn-ghost" }); + const cancelIc = cancelBtn.createSpan(); + setIcon(cancelIc, "cancel"); + cancelBtn.createSpan({ text: " Cancel" }); + cancelBtn.addEventListener("click", () => this.close()); + this.retryBtn = rightFooter.createEl("button", { cls: "cc-btn cc-btn-ghost cc-hidden" }); + const retryIc = this.retryBtn.createSpan(); + setIcon(retryIc, "retry"); + this.retryBtn.createSpan({ text: " Retry" }); + this.retryBtn.addEventListener("click", () => void this.retry()); + this.copyBtn = rightFooter.createEl("button", { cls: "cc-btn cc-btn-ghost cc-hidden" }); + const copyIc = this.copyBtn.createSpan(); + setIcon(copyIc, "copy"); + this.copyBtn.createSpan({ text: " Copy" }); + this.copyBtn.addEventListener("click", () => this.copyResponse()); + this.applyBtn = rightFooter.createEl("button", { cls: "cc-btn cc-btn-primary cc-hidden" }); + const applyIc = this.applyBtn.createSpan(); + setIcon(applyIc, "apply"); + this.applyBtn.createSpan({ text: " Apply" }); + this.applyBtn.addEventListener("click", () => this.applyResponse()); + this.scope.register([], "Escape", () => { + this.close(); + return false; + }); + setTimeout(() => this.inputEl.focus(), 80); + } + onClose() { + if (this.abortController) { + try { + this.abortController.abort(); + } catch (e) { + } + this.abortController = null; + } + this.contentEl.empty(); + } + populateModels() { + this.modelSelect.empty(); + const models = this.plugin.settings.savedModels.length > 0 ? this.plugin.settings.savedModels : [this.plugin.settings.defaultModel]; + let foundCurrent = false; + for (const m of models) { + const opt = this.modelSelect.createEl("option", { text: m, value: m }); + if (m === this.currentModel) { + opt.selected = true; + foundCurrent = true; + } + } + if (!foundCurrent && this.currentModel) { + const opt = this.modelSelect.createEl("option", { + text: this.currentModel, + value: this.currentModel + }); + opt.selected = true; + } + } + showError(msg) { + this.errorBanner.removeClass("cc-hidden"); + this.errorBanner.setText(`\u2717 ${msg}`); + } + clearError() { + this.errorBanner.addClass("cc-hidden"); + this.errorBanner.setText(""); + } + setStreaming(streaming) { + if (streaming) + this.responseEl.classList.add("cc-streaming"); + else + this.responseEl.classList.remove("cc-streaming"); + } + async send() { + const question = this.inputEl.value.trim(); + if (!question) { + this.showError("Please enter a question"); + return; + } + this.lastQuestion = question; + await this.runRequest(question); + } + async retry() { + if (!this.lastQuestion) + return; + await this.runRequest(this.lastQuestion); + } + async runRequest(question) { + var _a, _b, _c, _d; + this.clearError(); + this.responseText = ""; + this.responseEl.setText(""); + this.applyBtn.addClass("cc-hidden"); + this.copyBtn.addClass("cc-hidden"); + this.retryBtn.addClass("cc-hidden"); + this.sendBtn.setAttribute("disabled", "true"); + this.setStreaming(true); + const settings = this.plugin.settings; + if (!settings.apiBaseURL || !settings.apiKey) { + this.setStreaming(false); + this.sendBtn.removeAttribute("disabled"); + this.showError("Set API Base URL and API Key in plugin settings"); + return; + } + const userMessage = this.selectedText ? `Context: +""" +${this.selectedText} +""" + +Question: ${question}` : question; + const url = `${settings.apiBaseURL.replace(/\/$/, "")}/chat/completions`; + const body = { + model: this.currentModel, + messages: [ + { role: "system", content: settings.systemPrompt }, + { role: "user", content: userMessage } + ], + stream: settings.streamResponses, + max_tokens: settings.maxTokens, + temperature: settings.temperature + }; + if (this.abortController) { + try { + this.abortController.abort(); + } catch (e) { + } + } + this.abortController = new AbortController(); + try { + const resp = await fetch(url, { + method: "POST", + signal: this.abortController.signal, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${settings.apiKey}` + }, + body: JSON.stringify(body) + }); + if (!resp.ok) { + const text = await resp.text(); + throw new Error(`HTTP ${resp.status}: ${text.slice(0, 300)}`); + } + if (settings.streamResponses && resp.body) { + await this.readStream(resp.body); + } else { + const json = await resp.json(); + const content = (_d = (_c = (_b = (_a = json == null ? void 0 : json.choices) == null ? void 0 : _a[0]) == null ? void 0 : _b.message) == null ? void 0 : _c.content) != null ? _d : ""; + this.responseText = content; + this.responseEl.setText(content); + } + this.finishResponse(); + } catch (err) { + if ((err == null ? void 0 : err.name) === "AbortError") { + this.setStreaming(false); + this.sendBtn.removeAttribute("disabled"); + return; + } + const message = err instanceof Error ? err.message : String(err); + this.showError(message); + this.setStreaming(false); + this.sendBtn.removeAttribute("disabled"); + this.retryBtn.removeClass("cc-hidden"); + } + } + async readStream(body) { + var _a, _b, _c, _d, _e, _f, _g, _h, _i; + const reader = body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + while (true) { + const { value, done } = await reader.read(); + if (done) + break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = (_a = lines.pop()) != null ? _a : ""; + for (const raw of lines) { + const line = raw.trim(); + if (!line) + continue; + if (!line.startsWith("data:")) + continue; + const data = line.slice(5).trim(); + if (data === "[DONE]") + continue; + try { + const parsed = JSON.parse(data); + const delta = (_i = (_h = (_d = (_c = (_b = parsed == null ? void 0 : parsed.choices) == null ? void 0 : _b[0]) == null ? void 0 : _c.delta) == null ? void 0 : _d.content) != null ? _h : (_g = (_f = (_e = parsed == null ? void 0 : parsed.choices) == null ? void 0 : _e[0]) == null ? void 0 : _f.message) == null ? void 0 : _g.content) != null ? _i : ""; + if (delta) { + this.responseText += delta; + this.responseEl.setText(this.responseText); + } + } catch (e) { + } + } + } + } + finishResponse() { + this.setStreaming(false); + this.sendBtn.removeAttribute("disabled"); + if (this.responseText.length > 0) { + this.applyBtn.removeClass("cc-hidden"); + this.copyBtn.removeClass("cc-hidden"); + this.retryBtn.removeClass("cc-hidden"); + } + } + copyResponse() { + try { + void navigator.clipboard.writeText(this.responseText); + new import_obsidian.Notice("Copied to clipboard"); + } catch (e) { + new import_obsidian.Notice("Copy failed"); + } + } + applyResponse() { + this.onApply(this.responseText); + this.close(); + } +}; +var CourseCompanionSettingTab = class extends import_obsidian.PluginSettingTab { + constructor(app, plugin) { + super(app, plugin); + this.plugin = plugin; + } + display() { + const { containerEl } = this; + containerEl.empty(); + containerEl.addClass("cc-settings"); + containerEl.createEl("h2", { text: "AI Configuration" }); + new import_obsidian.Setting(containerEl).setName("API Base URL").setDesc("OpenAI-compatible endpoint").addText( + (t) => t.setPlaceholder("https://api.openai.com/v1").setValue(this.plugin.settings.apiBaseURL).onChange(async (v) => { + this.plugin.settings.apiBaseURL = v; + await this.plugin.saveSettings(); + }) + ); + new import_obsidian.Setting(containerEl).setName("API Key").setDesc("Stored locally in plugin data").addText((t) => { + t.setPlaceholder("sk-...").setValue(this.plugin.settings.apiKey).onChange(async (v) => { + this.plugin.settings.apiKey = v; + await this.plugin.saveSettings(); + }); + t.inputEl.type = "password"; + }); + new import_obsidian.Setting(containerEl).setName("Default Model").setDesc("Used when modal opens").addText( + (t) => t.setPlaceholder("gpt-4o-mini").setValue(this.plugin.settings.defaultModel).onChange(async (v) => { + this.plugin.settings.defaultModel = v; + await this.plugin.saveSettings(); + }) + ); + new import_obsidian.Setting(containerEl).setName("System Prompt").setDesc("Sent as the system message").addTextArea((t) => { + t.setValue(this.plugin.settings.systemPrompt).onChange(async (v) => { + this.plugin.settings.systemPrompt = v; + await this.plugin.saveSettings(); + }); + t.inputEl.rows = 4; + t.inputEl.classList.add("cc-textarea"); + }); + new import_obsidian.Setting(containerEl).setName("Max Tokens").setDesc("100\u20138000").addText((t) => { + t.setValue(String(this.plugin.settings.maxTokens)).onChange(async (v) => { + const n = parseInt(v, 10); + if (!isNaN(n) && n >= 100 && n <= 8e3) { + this.plugin.settings.maxTokens = n; + await this.plugin.saveSettings(); + } + }); + t.inputEl.type = "number"; + t.inputEl.min = "100"; + t.inputEl.max = "8000"; + }); + new import_obsidian.Setting(containerEl).setName("Temperature").setDesc("0.0\u20132.0").addSlider( + (s) => s.setLimits(0, 2, 0.1).setValue(this.plugin.settings.temperature).setDynamicTooltip().onChange(async (v) => { + this.plugin.settings.temperature = v; + await this.plugin.saveSettings(); + }) + ); + new import_obsidian.Setting(containerEl).setName("Stream responses").setDesc("Show AI output as it generates").addToggle( + (t) => t.setValue(this.plugin.settings.streamResponses).onChange(async (v) => { + this.plugin.settings.streamResponses = v; + await this.plugin.saveSettings(); + }) + ); + const testWrap = containerEl.createDiv({ cls: "cc-test-wrap" }); + const testBtn = testWrap.createEl("button", { cls: "cc-btn cc-btn-primary", text: "Test Connection" }); + const testResult = testWrap.createSpan({ cls: "cc-test-result" }); + testBtn.addEventListener("click", async () => { + testResult.setText("Testing..."); + testResult.removeClass("cc-success"); + testResult.removeClass("cc-error"); + try { + const s = this.plugin.settings; + if (!s.apiBaseURL || !s.apiKey) { + testResult.addClass("cc-error"); + testResult.setText("\u2717 Missing API Base URL or API Key"); + return; + } + const resp = await fetch(`${s.apiBaseURL.replace(/\/$/, "")}/chat/completions`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${s.apiKey}` + }, + body: JSON.stringify({ + model: s.defaultModel, + messages: [{ role: "user", content: "ping" }], + max_tokens: 4 + }) + }); + if (resp.ok) { + testResult.addClass("cc-success"); + testResult.setText("\u2713 Connected"); + } else { + testResult.addClass("cc-error"); + testResult.setText(`\u2717 Error: HTTP ${resp.status}`); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + testResult.addClass("cc-error"); + testResult.setText(`\u2717 Error: ${message}`); + } + }); + containerEl.createEl("h2", { text: "Saved Models" }); + const modelsList = containerEl.createDiv({ cls: "cc-models-list" }); + const renderModels = () => { + modelsList.empty(); + this.plugin.settings.savedModels.forEach((m, idx) => { + const row = modelsList.createDiv({ cls: "cc-model-row" }); + row.createSpan({ text: m, cls: "cc-model-name" }); + const del = row.createEl("button", { cls: "cc-icon-btn" }); + setIcon(del, "cancel"); + del.title = "Remove"; + del.addEventListener("click", async () => { + this.plugin.settings.savedModels.splice(idx, 1); + await this.plugin.saveSettings(); + renderModels(); + }); + }); + }; + renderModels(); + const addRow = containerEl.createDiv({ cls: "cc-add-model-row" }); + const addInput = addRow.createEl("input", { cls: "cc-add-model-input" }); + addInput.placeholder = "New model name (e.g. gpt-4o)"; + const addBtn = addRow.createEl("button", { cls: "cc-btn cc-btn-primary", text: "+ Add Model" }); + addBtn.addEventListener("click", async () => { + const v = addInput.value.trim(); + if (!v) + return; + if (this.plugin.settings.savedModels.includes(v)) { + new import_obsidian.Notice("Model already saved"); + return; + } + this.plugin.settings.savedModels.push(v); + await this.plugin.saveSettings(); + addInput.value = ""; + renderModels(); + }); + containerEl.createEl("h2", { text: "Highlight" }); + const hLabel = containerEl.createDiv({ cls: "cc-section-label" }); + hLabel.setText("Default highlight color"); + const swatchRow = containerEl.createDiv({ cls: "cc-swatch-row" }); + HIGHLIGHT_COLORS.forEach((c) => { + const sw = swatchRow.createEl("button", { cls: "cc-swatch" }); + sw.style.background = c.value; + sw.setAttribute("aria-label", c.name); + if (c.value === this.plugin.settings.defaultHighlightColor) { + sw.classList.add("cc-selected"); + } + sw.addEventListener("click", async () => { + this.plugin.settings.defaultHighlightColor = c.value; + this.plugin.settings.lastHighlightColor = c.value; + await this.plugin.saveSettings(); + swatchRow.querySelectorAll(".cc-swatch").forEach((el) => el.classList.remove("cc-selected")); + sw.classList.add("cc-selected"); + }); + }); + containerEl.createEl("h2", { text: "Shortcuts" }); + const shortcuts = containerEl.createDiv({ cls: "cc-shortcuts" }); + const rows = [ + ["Highlight selection", "Ctrl/Cmd + Shift + H"], + ["Insert table", "Command Palette \u2192 Table: Insert table"], + ["Ask AI", "Command Palette \u2192 Ask AI about selection / Right-click"] + ]; + rows.forEach(([label, key]) => { + const r = shortcuts.createDiv({ cls: "cc-shortcut-row" }); + r.createSpan({ cls: "cc-shortcut-label", text: label }); + r.createSpan({ cls: "cc-shortcut-key", text: key }); + }); + } +}; diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..f2b084e --- /dev/null +++ b/manifest.json @@ -0,0 +1,10 @@ +{ + "id": "art-de-one", + "name": "ART de ONE", + "version": "1.0.0", + "minAppVersion": "1.4.0", + "description": "Highlight text, build tables, and ask AI — all inside Obsidian.", + "author": "ART de ONE", + "authorUrl": "https://github.com/artdeone", + "isDesktopOnly": false +} \ No newline at end of file diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..f7b79c9 --- /dev/null +++ b/styles.css @@ -0,0 +1,494 @@ +/* ========================================================= + * Course Companion — full design system + animations + * ========================================================= */ + +:root { + --cc-bg: #0f0f0f; + --cc-surface: #1a1a1a; + --cc-surface-elev: #242424; + --cc-border: rgba(255, 255, 255, 0.08); + --cc-accent: #7c6dfa; + --cc-accent-hover: #9b8dff; + --cc-danger: #ff5757; + --cc-success: #4caf7d; + --cc-text: #f0f0f0; + --cc-text-muted: #888888; + --cc-text-disabled: #444444; + --cc-radius-sm: 6px; + --cc-radius-md: 10px; + --cc-radius-lg: 14px; + --cc-shadow-panel: 0 8px 32px rgba(0, 0, 0, 0.48), 0 2px 8px rgba(0, 0, 0, 0.32); + --cc-shadow-focus: 0 0 0 2px rgba(124, 109, 250, 0.4); + --cc-ease: cubic-bezier(0.16, 1, 0.3, 1); + --cc-font: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", sans-serif; + --cc-font-mono: "JetBrains Mono", "Fira Code", monospace; +} + +.cc-hidden { display: none !important; } + +/* --------------------------------------------------------- + * Buttons + * --------------------------------------------------------- */ +.cc-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 14px; + border-radius: var(--cc-radius-sm); + font-family: var(--cc-font); + font-size: 13px; + font-weight: 500; + letter-spacing: 0.02em; + border: 1px solid var(--cc-border); + background: var(--cc-surface); + color: var(--cc-text); + cursor: pointer; + min-height: 36px; + transition: transform 200ms var(--cc-ease), + background 200ms var(--cc-ease), + box-shadow 200ms var(--cc-ease), + border-color 200ms var(--cc-ease); +} +.cc-btn svg { width: 16px; height: 16px; } +.cc-btn:hover { + background: var(--cc-surface-elev); + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(124, 109, 250, 0.3); +} +.cc-btn:active { transform: translateY(0); } +.cc-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; + box-shadow: none; +} +.cc-btn-primary { + background: var(--cc-accent); + border-color: var(--cc-accent); + color: #fff; +} +.cc-btn-primary:hover { + background: var(--cc-accent-hover); + border-color: var(--cc-accent-hover); +} +.cc-btn-ghost { + background: transparent; + border-color: var(--cc-border); +} +.cc-btn-ghost:hover { background: var(--cc-surface-elev); } + +.cc-icon-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + min-width: 32px; + border-radius: var(--cc-radius-sm); + background: transparent; + border: 1px solid transparent; + color: var(--cc-text-muted); + cursor: pointer; + transition: background 200ms var(--cc-ease), color 200ms var(--cc-ease); +} +.cc-icon-btn:hover { background: var(--cc-surface-elev); color: var(--cc-text); } +.cc-icon-btn svg { width: 16px; height: 16px; } + +/* --------------------------------------------------------- + * Highlight popover + * --------------------------------------------------------- */ +.cc-highlight-popover { + position: absolute; + z-index: 1000; + display: flex; + gap: 8px; + padding: 10px 12px; + background: rgba(20, 20, 20, 0.96); + border: 1px solid var(--cc-border); + border-radius: 999px; + box-shadow: var(--cc-shadow-panel); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + opacity: 0; + transform: translateY(8px); + transition: opacity 200ms var(--cc-ease), transform 200ms var(--cc-ease); + pointer-events: auto; +} +.cc-highlight-popover.cc-visible { opacity: 1; transform: translateY(0); } + +.cc-swatch { + width: 22px; + height: 22px; + min-width: 22px; + border-radius: 50%; + border: 1px solid rgba(255, 255, 255, 0.18); + cursor: pointer; + transition: transform 150ms var(--cc-ease), box-shadow 150ms var(--cc-ease); + padding: 0; +} +.cc-swatch:hover { transform: scale(1.2); } +.cc-swatch.cc-selected { + box-shadow: 0 0 0 2px #fff, 0 2px 6px rgba(0, 0, 0, 0.4); +} + +/* --------------------------------------------------------- + * Modal base (used by grid picker and AI modal) + * --------------------------------------------------------- */ +.cc-modal { + background: rgba(18, 18, 18, 0.96); + border: 1px solid var(--cc-border); + border-radius: var(--cc-radius-lg); + box-shadow: var(--cc-shadow-panel); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + color: var(--cc-text); + font-family: var(--cc-font); + font-feature-settings: "ss01", "cv01"; + animation: cc-modal-in 300ms var(--cc-ease) both; +} +@keyframes cc-modal-in { + from { transform: translateY(16px) scale(0.97); opacity: 0; } + to { transform: none; opacity: 1; } +} + +.cc-section-label { + font-size: 12px; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--cc-text-muted); + margin: 12px 0 6px 0; +} + +/* --------------------------------------------------------- + * Table grid picker + * --------------------------------------------------------- */ +.cc-grid-picker { padding: 16px; min-width: 280px; } +.cc-grid-title { font-size: 15px; font-weight: 600; margin-bottom: 4px; } +.cc-grid-label { + font-family: var(--cc-font-mono); + font-size: 13px; + color: var(--cc-text-muted); + margin-bottom: 12px; +} +.cc-grid { + display: grid; + grid-template-columns: repeat(8, 22px); + grid-auto-rows: 22px; + gap: 4px; +} +.cc-grid-cell { + width: 22px; + height: 22px; + border-radius: 4px; + background: var(--cc-surface); + border: 1px solid var(--cc-border); + transition: background 150ms var(--cc-ease), border-color 150ms var(--cc-ease); + cursor: pointer; +} +.cc-grid-cell.cc-grid-active { + background: var(--cc-accent); + border-color: var(--cc-accent-hover); +} + +/* --------------------------------------------------------- + * Floating table toolbar + * --------------------------------------------------------- */ +.cc-table-toolbar { + position: absolute; + z-index: 900; + display: flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + background: rgba(20, 20, 20, 0.92); + border: 1px solid var(--cc-border); + border-radius: 24px; + box-shadow: var(--cc-shadow-panel); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + opacity: 0; + transform: translateY(6px); + transition: opacity 200ms var(--cc-ease), transform 200ms var(--cc-ease); + pointer-events: none; +} +.cc-table-toolbar.cc-visible { + opacity: 1; + transform: translateY(0); + pointer-events: auto; +} +.cc-tb-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 50%; + background: transparent; + border: none; + color: var(--cc-text-muted); + cursor: pointer; + padding: 0; + transition: background 150ms var(--cc-ease), color 150ms var(--cc-ease); +} +.cc-tb-btn:hover { + background: var(--cc-surface-elev); + color: var(--cc-text); +} +.cc-tb-btn svg { width: 16px; height: 16px; } +.cc-tb-divider { + width: 1px; + height: 20px; + background: rgba(255, 255, 255, 0.1); + margin: 0 2px; +} + +/* --------------------------------------------------------- + * AI modal + * --------------------------------------------------------- */ +.cc-ai-modal { max-width: 720px; width: 90vw; } +.cc-ai-modal .modal-content, +.cc-ai-modal { padding: 0; } + +.cc-ai-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 18px; + border-bottom: 1px solid var(--cc-border); +} +.cc-ai-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 15px; + font-weight: 600; + letter-spacing: 0.02em; +} +.cc-ai-icon { + display: inline-flex; + color: var(--cc-accent); +} +.cc-ai-icon svg { width: 18px; height: 18px; } + +.cc-model-wrap { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + background: var(--cc-surface); + border: 1px solid var(--cc-border); + border-radius: var(--cc-radius-sm); +} +.cc-model-icon { color: var(--cc-text-muted); display: inline-flex; } +.cc-model-icon svg { width: 14px; height: 14px; } + +.cc-model-select { + background: transparent; + color: var(--cc-text); + border: none; + font-family: var(--cc-font); + font-size: 13px; + outline: none; + cursor: pointer; + padding: 4px 4px; + appearance: none; + -webkit-appearance: none; +} +.cc-model-select option { + background: var(--cc-surface); + color: var(--cc-text); +} + +.cc-error-banner { + margin: 12px 18px 0 18px; + padding: 10px 12px; + border-radius: var(--cc-radius-sm); + background: rgba(255, 87, 87, 0.12); + border: 1px solid rgba(255, 87, 87, 0.35); + color: var(--cc-danger); + font-size: 13px; +} + +.cc-context-box { + margin: 0 18px; + padding: 10px 12px; + background: var(--cc-surface); + border: 1px solid var(--cc-border); + border-radius: var(--cc-radius-sm); + color: var(--cc-text-muted); + font-family: var(--cc-font-mono); + font-size: 12px; + line-height: 1.5; + max-height: 92px; + overflow-y: auto; + white-space: pre-wrap; +} + +.cc-ai-modal .cc-section-label { + margin-left: 18px; + margin-right: 18px; +} + +.cc-input-row { + display: flex; + gap: 8px; + padding: 0 18px; + align-items: stretch; +} +.cc-input { + flex: 1; + background: var(--cc-surface); + border: 1px solid var(--cc-border); + border-radius: var(--cc-radius-sm); + color: var(--cc-text); + padding: 10px 12px; + font-family: var(--cc-font); + font-size: 13px; + resize: vertical; + outline: none; + transition: border-color 200ms var(--cc-ease), box-shadow 200ms var(--cc-ease); +} +.cc-input:focus { + border-color: var(--cc-accent); + box-shadow: var(--cc-shadow-focus); +} +.cc-send-btn { flex-shrink: 0; } + +.cc-response { + margin: 6px 18px 0 18px; + padding: 14px 16px; + min-height: 120px; + max-height: 320px; + overflow-y: auto; + background: var(--cc-surface); + border: 1px solid var(--cc-border); + border-radius: var(--cc-radius-sm); + font-family: var(--cc-font-mono); + font-size: 13px; + line-height: 1.55; + color: var(--cc-text); + white-space: pre-wrap; + word-break: break-word; +} +.cc-response.cc-streaming::after { + content: "▋"; + margin-left: 2px; + color: var(--cc-accent); + animation: cc-cursor-blink 1s infinite; +} +@keyframes cc-cursor-blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0; } +} + +.cc-ai-footer { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + padding: 14px 18px; + margin-top: 12px; + border-top: 1px solid var(--cc-border); +} +.cc-footer-left, +.cc-footer-right { + display: flex; + gap: 8px; +} + +/* --------------------------------------------------------- + * Settings tab + * --------------------------------------------------------- */ +.cc-settings { font-family: var(--cc-font); } +.cc-settings h2 { + font-size: 15px; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--cc-text-muted); + margin-top: 24px; + margin-bottom: 8px; + border-bottom: 1px solid var(--cc-border); + padding-bottom: 6px; +} + +.cc-textarea { + min-width: 280px; + font-family: var(--cc-font-mono); + font-size: 12px; +} + +.cc-test-wrap { + display: flex; + align-items: center; + gap: 12px; + margin: 12px 0; +} +.cc-test-result { + font-size: 13px; + color: var(--cc-text-muted); +} +.cc-test-result.cc-success { color: var(--cc-success); } +.cc-test-result.cc-error { color: var(--cc-danger); } + +.cc-models-list { display: flex; flex-direction: column; gap: 4px; margin: 8px 0; } +.cc-model-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 12px; + background: var(--cc-surface); + border: 1px solid var(--cc-border); + border-radius: var(--cc-radius-sm); +} +.cc-model-name { font-family: var(--cc-font-mono); font-size: 13px; } + +.cc-add-model-row { + display: flex; + gap: 8px; + margin: 8px 0 16px 0; +} +.cc-add-model-input { + flex: 1; + padding: 8px 12px; + background: var(--cc-surface); + border: 1px solid var(--cc-border); + border-radius: var(--cc-radius-sm); + color: var(--cc-text); + font-family: var(--cc-font); + font-size: 13px; + outline: none; + transition: border-color 200ms var(--cc-ease); +} +.cc-add-model-input:focus { border-color: var(--cc-accent); } + +.cc-swatch-row { display: flex; gap: 10px; margin: 8px 0 16px 0; } + +.cc-shortcuts { + display: flex; + flex-direction: column; + gap: 4px; + margin: 8px 0; +} +.cc-shortcut-row { + display: flex; + justify-content: space-between; + padding: 8px 12px; + background: var(--cc-surface); + border: 1px solid var(--cc-border); + border-radius: var(--cc-radius-sm); + font-size: 13px; +} +.cc-shortcut-label { color: var(--cc-text); } +.cc-shortcut-key { color: var(--cc-text-muted); font-family: var(--cc-font-mono); } + +/* --------------------------------------------------------- + * Mobile touch targets + * --------------------------------------------------------- */ +@media (pointer: coarse) { + .cc-btn { min-height: 44px; padding: 10px 16px; } + .cc-icon-btn { width: 44px; height: 44px; min-width: 44px; } + .cc-tb-btn { width: 44px; height: 44px; } + .cc-swatch { width: 32px; height: 32px; min-width: 32px; } + .cc-grid-cell { width: 28px; height: 28px; } + .cc-grid { grid-template-columns: repeat(8, 28px); grid-auto-rows: 28px; } +}