🎉 initial commit

This commit is contained in:
2026-02-20 14:17:34 +10:00
commit e21d1c7b58
33 changed files with 8207 additions and 0 deletions

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
node_modules/
out/
dist/
.env.local
.env.*.local
*.log
.DS_Store

20
electron.vite.config.ts Normal file
View File

@@ -0,0 +1,20 @@
import { resolve } from 'path'
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
main: {
plugins: [externalizeDepsPlugin()]
},
preload: {
plugins: [externalizeDepsPlugin()]
},
renderer: {
resolve: {
alias: {
'@renderer': resolve('src/renderer')
}
},
plugins: [react()]
}
})

6236
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

49
package.json Normal file
View File

@@ -0,0 +1,49 @@
{
"name": "hohoff-editor",
"version": "1.0.0",
"description": "Novel editor for Hohoff",
"main": "out/main/index.js",
"scripts": {
"dev": "electron-vite dev",
"build": "electron-vite build",
"preview": "electron-vite preview",
"typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.web.json"
},
"devDependencies": {
"@electron-toolkit/tsconfig": "^1.0.1",
"@types/node": "^20.0.0",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^4.2.0",
"electron": "^33.0.0",
"electron-builder": "^24.0.0",
"electron-vite": "^2.3.0",
"typescript": "^5.4.0",
"vite": "^5.2.0"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.29.0",
"@codemirror/commands": "^6.6.0",
"@codemirror/lang-markdown": "^6.3.0",
"@codemirror/language": "^6.10.0",
"@codemirror/search": "^6.5.0",
"@codemirror/state": "^6.4.0",
"@codemirror/view": "^6.35.0",
"@electron-toolkit/preload": "^3.0.0",
"@electron-toolkit/utils": "^3.0.0",
"codemirror": "^6.0.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"zustand": "^4.5.0"
},
"build": {
"appId": "com.hohoff.editor",
"productName": "Hohoff Editor",
"mac": {
"target": "dmg"
},
"files": [
"out/**/*"
]
}
}

101
src/main/aiService.ts Normal file
View File

@@ -0,0 +1,101 @@
import Anthropic from '@anthropic-ai/sdk'
import type { AIPayload } from '../renderer/types/editor'
let _client: Anthropic | null = null
function getClient(): Anthropic {
if (!_client) {
const apiKey = process.env.ANTHROPIC_API_KEY
if (!apiKey || apiKey === 'your-api-key-here') {
throw new Error(
'ANTHROPIC_API_KEY is not set. Add it to app/.env.local'
)
}
_client = new Anthropic({ apiKey })
}
return _client
}
function buildSystemPrompt(payload: AIPayload): string {
const chapterName =
payload.documentPath.split('/').pop()?.replace(/\.md$/, '') ?? 'Unknown chapter'
const chapterContext = `You are a literary editor assistant helping with a gothic/historical fiction novel set in the Basque Country. The current chapter is: "${chapterName}".
Key characters: Esti, Marko, Garbi, Irati, Cardinal Nikolai, Amaya, Izotz, Sua, Señor Jiménez, the Genboa family.
The chapter text is provided below. When identifying specific passages, quote the EXACT text from the document so it can be located and highlighted in the editor.
--- CHAPTER ---
${payload.documentContent}
--- END CHAPTER ---`
const modeInstructions: Record<AIPayload['mode'], string> = {
chat: 'Answer questions about the chapter, characters, plot, or craft. Be specific and cite passages where relevant.',
passive_voice: `Identify ALL instances of passive voice in this chapter.
For each instance, respond in this exact format:
PASSIVE: "[exact quoted sentence]"
WHY: [brief explanation]
SUGGESTION: "[rewritten in active voice]"
List every instance you find, then give a brief overall summary.`,
consistency: `Check this chapter carefully for consistency issues:
- Character names spelled or used inconsistently
- Timeline contradictions or impossibilities
- Repeated words or phrases appearing too close together (within a page)
- Setting details that seem contradictory
- Character behaviour inconsistent with their established personality
For each issue found:
ISSUE: [type of issue]
PASSAGE: "[exact quoted text]"
PROBLEM: [explanation]
SUGGESTION: [how to fix it]`,
style: `Analyze the writing style and provide specific improvement suggestions:
- Pacing: identify slow passages or rushed moments
- Sentence variety: flag runs of similar length or structure
- Show don't tell: identify passages that tell emotion/state rather than showing it
- Gothic atmosphere: passages where the atmospheric tone is inconsistent
- Dialogue: any dialogue that feels stilted or unnatural
For each suggestion:
ISSUE: [type: Pacing / Sentence Variety / Show-Don't-Tell / Atmosphere / Dialogue]
PASSAGE: "[exact quoted text]"
PROBLEM: [specific explanation]
SUGGESTION: [concrete rewrite or approach]`
}
return `${chapterContext}\n\n${modeInstructions[payload.mode]}`
}
export async function streamMessage(
payload: AIPayload,
onChunk: (chunk: string) => void
): Promise<void> {
const client = getClient()
const messages = [
...payload.conversationHistory.slice(-10),
{ role: 'user' as const, content: payload.userMessage }
]
const stream = client.messages.stream({
model: 'claude-sonnet-4-5',
max_tokens: 4096,
system: buildSystemPrompt(payload),
messages
})
for await (const chunk of stream) {
if (
chunk.type === 'content_block_delta' &&
chunk.delta.type === 'text_delta'
) {
onChunk(chunk.delta.text)
}
}
}

71
src/main/fileSystem.ts Normal file
View File

@@ -0,0 +1,71 @@
import { readdir, readFile, writeFile } from 'fs/promises'
import { join } from 'path'
import type { FileNode } from '../renderer/types/editor'
const DRAFT_ROOT =
process.env.DRAFT_PATH ?? '/Users/pori/WebstormProjects/hohoff/draft'
const PART_ORDER = ['Prologue', 'Content Warning', 'Part I', 'Part II', 'Part III', 'Part IV', 'Epilogue', 'The first time']
function sortDraftNodes(a: FileNode, b: FileNode): number {
const aIdx = PART_ORDER.findIndex((o) => a.name.startsWith(o))
const bIdx = PART_ORDER.findIndex((o) => b.name.startsWith(o))
if (aIdx !== -1 && bIdx !== -1) return aIdx - bIdx
if (aIdx !== -1) return -1
if (bIdx !== -1) return 1
return a.name.localeCompare(b.name)
}
export async function listDraftFiles(): Promise<FileNode[]> {
const entries = await readdir(DRAFT_ROOT, { withFileTypes: true })
const nodes: FileNode[] = []
for (const entry of entries) {
if (entry.name.startsWith('.')) continue
const fullPath = join(DRAFT_ROOT, entry.name)
if (entry.isDirectory()) {
const children = await readdir(fullPath, { withFileTypes: true })
const childNodes: FileNode[] = children
.filter((c) => c.name.endsWith('.md') && !c.name.startsWith('.'))
.map((c) => ({
name: c.name.replace(/\.md$/, ''),
path: join(fullPath, c.name),
type: 'file' as const
}))
.sort((a, b) => a.name.localeCompare(b.name))
nodes.push({
name: entry.name,
path: fullPath,
type: 'directory',
children: childNodes
})
} else if (entry.name.endsWith('.md')) {
nodes.push({
name: entry.name.replace(/\.md$/, ''),
path: fullPath,
type: 'file'
})
}
}
return nodes.sort(sortDraftNodes)
}
function assertInDraftRoot(filePath: string): void {
const resolved = filePath.startsWith('/') ? filePath : join(DRAFT_ROOT, filePath)
if (!resolved.startsWith(DRAFT_ROOT)) {
throw new Error('Access denied: path outside draft directory')
}
}
export async function readMarkdownFile(filePath: string): Promise<string> {
assertInDraftRoot(filePath)
return await readFile(filePath, 'utf-8')
}
export async function writeMarkdownFile(filePath: string, content: string): Promise<void> {
assertInDraftRoot(filePath)
await writeFile(filePath, content, 'utf-8')
}

65
src/main/index.ts Normal file
View File

@@ -0,0 +1,65 @@
import { app, BrowserWindow, shell } from 'electron'
import { join, resolve } from 'path'
import { config } from 'dotenv'
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import { registerIpcHandlers } from './ipcHandlers'
// Load .env then .env.local so values in .env.local override .env
config({ path: resolve(process.cwd(), '.env') })
config({ path: resolve(process.cwd(), '.env.local'), override: true })
function createWindow(): void {
const mainWindow = new BrowserWindow({
width: 1440,
height: 900,
minWidth: 900,
minHeight: 600,
title: 'Hohoff Editor',
show: false,
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false,
contextIsolation: true,
nodeIntegration: false
}
})
mainWindow.on('ready-to-show', () => {
mainWindow.show()
})
mainWindow.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url)
return { action: 'deny' }
})
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
mainWindow.webContents.openDevTools({ mode: 'detach' })
} else {
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
}
}
app.whenReady().then(() => {
electronApp.setAppUserModelId('com.hohoff.editor')
app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window)
})
registerIpcHandlers()
createWindow()
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow()
}
})
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
}
})

36
src/main/ipcHandlers.ts Normal file
View File

@@ -0,0 +1,36 @@
import { ipcMain } from 'electron'
import { listDraftFiles, readMarkdownFile, writeMarkdownFile } from './fileSystem'
import { streamMessage } from './aiService'
import type { AIPayload } from '../renderer/types/editor'
export function registerIpcHandlers(): void {
ipcMain.handle('fs:listFiles', async () => {
return await listDraftFiles()
})
ipcMain.handle('fs:readFile', async (_event, filePath: string) => {
return await readMarkdownFile(filePath)
})
ipcMain.handle('fs:writeFile', async (_event, filePath: string, content: string) => {
await writeMarkdownFile(filePath, content)
})
ipcMain.handle('ai:streamMessage', async (event, payload: AIPayload) => {
try {
await streamMessage(payload, (chunk: string) => {
if (!event.sender.isDestroyed()) {
event.sender.send('ai:chunk', chunk)
}
})
if (!event.sender.isDestroyed()) {
event.sender.send('ai:done')
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
if (!event.sender.isDestroyed()) {
event.sender.send('ai:error', message)
}
}
})
}

47
src/preload/index.ts Normal file
View File

@@ -0,0 +1,47 @@
import { contextBridge, ipcRenderer } from 'electron'
import type { FileNode, AIPayload } from '../renderer/types/editor'
contextBridge.exposeInMainWorld('api', {
listFiles: (): Promise<FileNode[]> => ipcRenderer.invoke('fs:listFiles'),
readFile: (filePath: string): Promise<string> =>
ipcRenderer.invoke('fs:readFile', filePath),
writeFile: (filePath: string, content: string): Promise<void> =>
ipcRenderer.invoke('fs:writeFile', filePath, content),
streamAIMessage: (
payload: AIPayload,
onChunk: (chunk: string) => void
): Promise<void> => {
return new Promise((resolve, reject) => {
const chunkHandler = (_: Electron.IpcRendererEvent, chunk: string): void => {
onChunk(chunk)
}
const doneHandler = (): void => {
ipcRenderer.removeListener('ai:chunk', chunkHandler)
ipcRenderer.removeListener('ai:done', doneHandler)
ipcRenderer.removeListener('ai:error', errorHandler)
resolve()
}
const errorHandler = (_: Electron.IpcRendererEvent, message: string): void => {
ipcRenderer.removeListener('ai:chunk', chunkHandler)
ipcRenderer.removeListener('ai:done', doneHandler)
ipcRenderer.removeListener('ai:error', errorHandler)
reject(new Error(message))
}
ipcRenderer.on('ai:chunk', chunkHandler)
ipcRenderer.on('ai:done', doneHandler)
ipcRenderer.on('ai:error', errorHandler)
ipcRenderer.invoke('ai:streamMessage', payload).catch(reject)
})
},
removeAIListener: (): void => {
ipcRenderer.removeAllListeners('ai:chunk')
ipcRenderer.removeAllListeners('ai:done')
ipcRenderer.removeAllListeners('ai:error')
}
})

47
src/renderer/App.tsx Normal file
View File

@@ -0,0 +1,47 @@
import { useEffect } from 'react'
import { FileTree } from './components/FileTree/FileTree'
import { MarkdownEditor } from './components/Editor/MarkdownEditor'
import { ChatPanel } from './components/AIChat/ChatPanel'
import { AnalysisToolbar } from './components/Toolbar/AnalysisToolbar'
import { useEditorStore } from './store/editorStore'
import './styles/app.css'
export default function App(): JSX.Element {
const { setFileTree, activeFilePath, isDirty, markSaved, activeFileContent } =
useEditorStore()
// Load file tree on mount
useEffect(() => {
window.api.listFiles().then(setFileTree)
}, [])
// Handle Cmd+S / Ctrl+S
useEffect(() => {
const handler = async (e: KeyboardEvent): Promise<void> => {
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
e.preventDefault()
if (activeFilePath && isDirty) {
await window.api.writeFile(activeFilePath, activeFileContent)
markSaved()
}
}
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [activeFilePath, isDirty, activeFileContent])
return (
<div className="app-layout">
<aside className="sidebar">
<FileTree />
</aside>
<main className="editor-area">
<AnalysisToolbar />
<MarkdownEditor />
</main>
<aside className="chat-area">
<ChatPanel />
</aside>
</div>
)
}

View File

@@ -0,0 +1,196 @@
.chat-panel {
display: flex;
flex-direction: column;
height: 100%;
background: var(--sidebar-bg);
border-left: 1px solid var(--border);
}
.chat-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 14px 10px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-muted);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.chat-clear-btn {
background: none;
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text-muted);
font-size: 10px;
padding: 2px 7px;
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
}
.chat-clear-btn:hover {
color: var(--text-primary);
border-color: var(--text-muted);
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 12px;
display: flex;
flex-direction: column;
gap: 12px;
}
.chat-placeholder {
color: var(--text-muted);
font-size: 13px;
font-family: var(--font-serif);
font-style: italic;
text-align: center;
margin: 24px 0;
line-height: 1.6;
}
.chat-message {
display: flex;
flex-direction: column;
gap: 4px;
}
.chat-message-label {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-muted);
}
.chat-message-user .chat-message-label {
color: var(--accent);
}
.chat-message-content {
font-size: 13px;
line-height: 1.65;
color: var(--text-primary);
font-family: var(--font-sans);
white-space: pre-wrap;
word-break: break-word;
background: var(--message-bg);
border-radius: 6px;
padding: 10px 12px;
}
.chat-message-user .chat-message-content {
background: var(--user-message-bg);
}
.chat-streaming-cursor {
animation: blink 1s step-end infinite;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
.chat-typing {
display: flex;
gap: 4px;
padding: 10px 12px;
background: var(--message-bg);
border-radius: 6px;
width: fit-content;
}
.chat-typing span {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--text-muted);
animation: bounce 1.2s ease-in-out infinite;
}
.chat-typing span:nth-child(2) { animation-delay: 0.2s; }
.chat-typing span:nth-child(3) { animation-delay: 0.4s; }
@keyframes bounce {
0%, 80%, 100% { transform: translateY(0); }
40% { transform: translateY(-5px); }
}
.chat-error {
background: rgba(200, 60, 60, 0.15);
border: 1px solid rgba(200, 60, 60, 0.4);
border-radius: 6px;
color: #e07070;
font-size: 12px;
padding: 10px 12px;
}
.chat-input-area {
display: flex;
align-items: flex-end;
gap: 8px;
padding: 10px 12px;
border-top: 1px solid var(--border);
flex-shrink: 0;
}
.chat-textarea {
flex: 1;
background: var(--input-bg);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-primary);
font-family: var(--font-sans);
font-size: 13px;
line-height: 1.5;
padding: 8px 10px;
resize: none;
outline: none;
transition: border-color 0.15s;
min-height: 36px;
}
.chat-textarea:focus {
border-color: var(--accent);
}
.chat-textarea::placeholder {
color: var(--text-muted);
}
.chat-textarea:disabled {
opacity: 0.5;
}
.chat-send-btn {
background: var(--accent);
border: none;
border-radius: 6px;
color: #1a1208;
font-size: 16px;
font-weight: 700;
width: 34px;
height: 34px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: opacity 0.15s;
}
.chat-send-btn:disabled {
opacity: 0.35;
cursor: default;
}
.chat-send-btn:not(:disabled):hover {
opacity: 0.85;
}

View File

@@ -0,0 +1,60 @@
import { useState, useRef, type KeyboardEvent } from 'react'
interface Props {
onSend: (text: string) => void
disabled: boolean
}
export function ChatInput({ onSend, disabled }: Props): JSX.Element {
const [value, setValue] = useState('')
const textareaRef = useRef<HTMLTextAreaElement>(null)
const submit = (): void => {
const trimmed = value.trim()
if (!trimmed || disabled) return
onSend(trimmed)
setValue('')
if (textareaRef.current) {
textareaRef.current.style.height = 'auto'
}
}
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>): void => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
submit()
}
}
const handleInput = (): void => {
const el = textareaRef.current
if (el) {
el.style.height = 'auto'
el.style.height = `${Math.min(el.scrollHeight, 160)}px`
}
}
return (
<div className="chat-input-area">
<textarea
ref={textareaRef}
className="chat-textarea"
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
onInput={handleInput}
placeholder={disabled ? 'Open a chapter first…' : 'Ask about this chapter… (Enter to send)'}
disabled={disabled}
rows={1}
/>
<button
className="chat-send-btn"
onClick={submit}
disabled={disabled || !value.trim()}
aria-label="Send"
>
</button>
</div>
)
}

View File

@@ -0,0 +1,18 @@
import type { ChatMessage } from '../../types/editor'
interface Props {
message: ChatMessage
}
export function ChatMessageItem({ message }: Props): JSX.Element {
return (
<div className={`chat-message chat-message-${message.role}`}>
<div className="chat-message-label">
{message.role === 'user' ? 'You' : 'Editor AI'}
</div>
<div className="chat-message-content">
{message.content || <span className="chat-streaming-cursor"></span>}
</div>
</div>
)
}

View File

@@ -0,0 +1,117 @@
import { useRef, useEffect } from 'react'
import { useEditorStore } from '../../store/editorStore'
import { ChatMessageItem } from './ChatMessageItem'
import { ChatInput } from './ChatInput'
import { parseAnnotationsFromAIResponse } from '../../utils/annotationParser'
import './Chat.css'
export function ChatPanel(): JSX.Element {
const {
chatHistory,
isAILoading,
aiError,
activeFileContent,
activeFilePath,
analysisMode,
addUserMessage,
startAssistantMessage,
appendToLastAssistantMessage,
setAILoading,
setAIError,
setAnnotations,
clearChat
} = useEditorStore()
const scrollRef = useRef<HTMLDivElement>(null)
const sendMessage = async (text: string): Promise<void> => {
if (!activeFilePath || isAILoading) return
setAIError(null)
addUserMessage(text)
startAssistantMessage()
setAILoading(true)
try {
const mode = analysisMode === 'none' ? 'chat' : analysisMode
await window.api.streamAIMessage(
{
mode,
documentContent: activeFileContent,
documentPath: activeFilePath,
conversationHistory: chatHistory
.slice(-10)
.map((m) => ({ role: m.role, content: m.content })),
userMessage: text
},
(chunk: string) => {
appendToLastAssistantMessage(chunk)
}
)
// After streaming, parse AI response for annotations
const currentHistory = useEditorStore.getState().chatHistory
const lastMsg = currentHistory[currentHistory.length - 1]
if (lastMsg?.role === 'assistant' && lastMsg.content.length > 0) {
const annotations = parseAnnotationsFromAIResponse(lastMsg.content, activeFileContent)
if (annotations.length > 0) {
setAnnotations(annotations)
}
}
} catch (err) {
const message = err instanceof Error ? err.message : 'An error occurred'
setAIError(message)
} finally {
setAILoading(false)
}
}
// Auto-scroll to bottom when new content arrives
useEffect(() => {
const el = scrollRef.current
if (el) {
el.scrollTop = el.scrollHeight
}
}, [chatHistory])
const hasFile = Boolean(activeFilePath)
return (
<div className="chat-panel">
<div className="chat-header">
<span>AI Editor</span>
{chatHistory.length > 0 && (
<button className="chat-clear-btn" onClick={clearChat} title="Clear conversation">
Clear
</button>
)}
</div>
<div className="chat-messages" ref={scrollRef}>
{!hasFile && (
<p className="chat-placeholder">Open a chapter to start a conversation about it.</p>
)}
{hasFile && chatHistory.length === 0 && (
<p className="chat-placeholder">
Ask anything about the current chapter passive voice, plot, character, style...
</p>
)}
{chatHistory.map((msg) => (
<ChatMessageItem key={msg.id} message={msg} />
))}
{isAILoading && chatHistory[chatHistory.length - 1]?.content === '' && (
<div className="chat-typing">
<span />
<span />
<span />
</div>
)}
{aiError && (
<div className="chat-error">{aiError}</div>
)}
</div>
<ChatInput onSend={sendMessage} disabled={!hasFile || isAILoading} />
</div>
)
}

View File

@@ -0,0 +1,38 @@
.editor-container {
position: relative;
height: 100%;
overflow: hidden;
}
.codemirror-host {
height: 100%;
}
.codemirror-host .cm-editor {
height: 100%;
outline: none;
}
.editor-empty {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
color: var(--text-muted);
font-family: var(--font-serif);
font-size: 15px;
pointer-events: none;
z-index: 1;
}
.editor-empty p {
margin: 0;
}
.editor-empty-hint {
font-size: 12px;
opacity: 0.6;
}

View File

@@ -0,0 +1,172 @@
import { useEffect, useRef } from 'react'
import { EditorView, Decoration, type DecorationSet } from '@codemirror/view'
import { EditorState, StateField, StateEffect, RangeSetBuilder } from '@codemirror/state'
import { markdown } from '@codemirror/lang-markdown'
import { syntaxHighlighting, defaultHighlightStyle } from '@codemirror/language'
import { history, defaultKeymap, historyKeymap } from '@codemirror/commands'
import { keymap } from '@codemirror/view'
import { useEditorStore } from '../../store/editorStore'
import type { TextAnnotation } from '../../types/editor'
import './Editor.css'
// StateEffect to push new annotations into the editor
export const setAnnotationsEffect = StateEffect.define<TextAnnotation[]>()
// StateField tracks the decoration set derived from annotations
const annotationField = StateField.define<DecorationSet>({
create: () => Decoration.none,
update(deco, tr) {
deco = deco.map(tr.changes)
for (const effect of tr.effects) {
if (effect.is(setAnnotationsEffect)) {
const builder = new RangeSetBuilder<Decoration>()
const sorted = [...effect.value].sort((a, b) => a.from - b.from)
for (const ann of sorted) {
const docLen = tr.newDoc.length
const from = Math.max(0, Math.min(ann.from, docLen))
const to = Math.max(from, Math.min(ann.to, docLen))
if (from < to) {
builder.add(
from,
to,
Decoration.mark({
class: `annotation annotation-${ann.type}`,
attributes: { 'data-id': ann.id, title: ann.message }
})
)
}
}
return builder.finish()
}
}
return deco
},
provide: (f) => EditorView.decorations.from(f)
})
// Dark gothic theme for CodeMirror
const hohoffTheme = EditorView.theme(
{
'&': {
height: '100%',
fontSize: '15px',
backgroundColor: 'transparent',
color: 'var(--text-primary)'
},
'.cm-scroller': {
fontFamily: 'var(--font-serif)',
lineHeight: '1.8',
overflow: 'auto'
},
'.cm-content': {
padding: '24px 32px',
maxWidth: '740px',
margin: '0 auto',
caretColor: 'var(--accent)'
},
'.cm-cursor': { borderLeftColor: 'var(--accent)' },
'.cm-selectionBackground': { backgroundColor: 'rgba(167,139,95,0.2)' },
'&.cm-focused .cm-selectionBackground': { backgroundColor: 'rgba(167,139,95,0.3)' },
'.cm-line': { padding: '0' },
'.cm-gutters': { display: 'none' },
'.cm-activeLine': { backgroundColor: 'transparent' },
'.cm-activeLineGutter': { backgroundColor: 'transparent' },
// Markdown heading styles
'.tok-heading': { fontWeight: '700', color: 'var(--heading-color)' },
'.tok-heading1': { fontSize: '1.4em' },
'.tok-heading2': { fontSize: '1.2em' },
'.tok-emphasis': { fontStyle: 'italic' },
'.tok-strong': { fontWeight: '700' },
// Annotation highlight styles
'.annotation-passive_voice': {
backgroundColor: 'rgba(255, 200, 0, 0.18)',
borderBottom: '2px solid rgba(255, 200, 0, 0.7)',
borderRadius: '2px'
},
'.annotation-consistency': {
backgroundColor: 'rgba(220, 80, 80, 0.18)',
borderBottom: '2px solid rgba(220, 80, 80, 0.7)',
borderRadius: '2px'
},
'.annotation-style': {
backgroundColor: 'rgba(80, 160, 255, 0.18)',
borderBottom: '2px solid rgba(80, 160, 255, 0.7)',
borderRadius: '2px'
}
},
{ dark: true }
)
export function MarkdownEditor(): JSX.Element {
const containerRef = useRef<HTMLDivElement>(null)
const viewRef = useRef<EditorView | null>(null)
const { activeFilePath, activeFileContent, setContent, annotations } = useEditorStore()
// Initialize CodeMirror once
useEffect(() => {
if (!containerRef.current) return
const view = new EditorView({
state: EditorState.create({
doc: '',
extensions: [
history(),
keymap.of([...defaultKeymap, ...historyKeymap]),
markdown(),
syntaxHighlighting(defaultHighlightStyle),
annotationField,
hohoffTheme,
EditorView.lineWrapping,
EditorView.updateListener.of((update) => {
if (update.docChanged) {
setContent(update.state.doc.toString())
}
})
]
}),
parent: containerRef.current
})
viewRef.current = view
return () => {
view.destroy()
viewRef.current = null
}
}, []) // eslint-disable-line react-hooks/exhaustive-deps
// When the active file changes, replace editor content
useEffect(() => {
const view = viewRef.current
if (!view) return
const current = view.state.doc.toString()
if (current !== activeFileContent) {
view.dispatch({
changes: { from: 0, to: current.length, insert: activeFileContent }
})
// Scroll to top on file switch
view.dispatch({ selection: { anchor: 0 } })
view.scrollDOM.scrollTop = 0
}
}, [activeFilePath]) // Only sync on file switch
// Push annotation decorations into CodeMirror
useEffect(() => {
const view = viewRef.current
if (!view) return
view.dispatch({ effects: setAnnotationsEffect.of(annotations) })
}, [annotations])
return (
<div className="editor-container">
{!activeFilePath && (
<div className="editor-empty">
<p>Select a chapter from the sidebar to begin editing.</p>
<p className="editor-empty-hint">Your draft files will never be modified without saving (Cmd+S).</p>
</div>
)}
<div ref={containerRef} className="codemirror-host" />
</div>
)
}

View File

@@ -0,0 +1,80 @@
.file-tree {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.file-tree-header {
padding: 14px 12px 10px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.12em;
color: var(--text-muted);
border-bottom: 1px solid var(--border);
text-transform: uppercase;
}
.file-tree-list {
overflow-y: auto;
flex: 1;
padding: 6px 0;
}
.tree-dir-header {
display: flex;
align-items: center;
gap: 5px;
width: 100%;
background: none;
border: none;
color: var(--text-secondary);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
padding: 6px 8px;
cursor: pointer;
text-align: left;
transition: color 0.15s;
}
.tree-dir-header:hover {
color: var(--text-primary);
}
.tree-arrow {
font-size: 10px;
width: 10px;
flex-shrink: 0;
}
.tree-file {
display: block;
width: 100%;
background: none;
border: none;
color: var(--text-muted);
font-family: var(--font-serif);
font-size: 12px;
padding: 5px 8px;
cursor: pointer;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: color 0.15s, background 0.15s;
border-radius: 3px;
margin: 0 4px;
width: calc(100% - 8px);
}
.tree-file:hover {
color: var(--text-primary);
background: var(--hover-bg);
}
.tree-file.active {
color: var(--accent);
background: var(--active-bg);
}

View File

@@ -0,0 +1,18 @@
import { useEditorStore } from '../../store/editorStore'
import { FileTreeNode } from './FileTreeNode'
import './FileTree.css'
export function FileTree(): JSX.Element {
const { fileTree } = useEditorStore()
return (
<nav className="file-tree">
<div className="file-tree-header">HOHOFF</div>
<div className="file-tree-list">
{fileTree.map((node) => (
<FileTreeNode key={node.path} node={node} depth={0} />
))}
</div>
</nav>
)
}

View File

@@ -0,0 +1,51 @@
import { useState } from 'react'
import { useEditorStore } from '../../store/editorStore'
import type { FileNode } from '../../types/editor'
interface Props {
node: FileNode
depth: number
}
export function FileTreeNode({ node, depth }: Props): JSX.Element {
const [expanded, setExpanded] = useState(true)
const { activeFilePath, setActiveFile } = useEditorStore()
const openFile = async (): Promise<void> => {
if (node.type === 'file') {
const content = await window.api.readFile(node.path)
setActiveFile(node.path, content)
}
}
if (node.type === 'directory') {
return (
<div className="tree-dir">
<button
className="tree-dir-header"
style={{ paddingLeft: `${depth * 12 + 8}px` }}
onClick={() => setExpanded(!expanded)}
>
<span className="tree-arrow">{expanded ? '▾' : '▸'}</span>
{node.name}
</button>
{expanded && node.children?.map((child) => (
<FileTreeNode key={child.path} node={child} depth={depth + 1} />
))}
</div>
)
}
const isActive = activeFilePath === node.path
return (
<button
className={`tree-file${isActive ? ' active' : ''}`}
style={{ paddingLeft: `${depth * 12 + 20}px` }}
onClick={openFile}
title={node.name}
>
{node.name}
</button>
)
}

View File

@@ -0,0 +1,146 @@
import { useEditorStore } from '../../store/editorStore'
import { detectPassiveVoice } from '../../utils/passiveVoice'
import { parseAnnotationsFromAIResponse } from '../../utils/annotationParser'
import './Toolbar.css'
export function AnalysisToolbar(): JSX.Element {
const {
activeFilePath,
activeFileContent,
isDirty,
analysisMode,
annotations,
isAILoading,
setAnnotations,
clearAnnotations,
setAnalysisMode,
addUserMessage,
startAssistantMessage,
appendToLastAssistantMessage,
setAILoading,
setAIError,
chatHistory
} = useEditorStore()
const hasFile = Boolean(activeFilePath)
const runPassiveVoice = (): void => {
if (!hasFile) return
const found = detectPassiveVoice(activeFileContent)
setAnnotations(found)
setAnalysisMode('passive_voice')
}
const runAIAnalysis = async (mode: 'consistency' | 'style'): Promise<void> => {
if (!activeFilePath || isAILoading) return
setAnalysisMode(mode)
setAIError(null)
const prompt =
mode === 'consistency'
? 'Please check this chapter for consistency issues (character names, timeline, repeated phrases).'
: 'Please analyze the style and pacing of this chapter and suggest improvements.'
addUserMessage(prompt)
startAssistantMessage()
setAILoading(true)
try {
await window.api.streamAIMessage(
{
mode,
documentContent: activeFileContent,
documentPath: activeFilePath,
conversationHistory: chatHistory
.slice(-10)
.map((m) => ({ role: m.role, content: m.content })),
userMessage: prompt
},
(chunk: string) => {
appendToLastAssistantMessage(chunk)
}
)
// Parse annotations from response
const currentHistory = useEditorStore.getState().chatHistory
const lastMsg = currentHistory[currentHistory.length - 1]
if (lastMsg?.role === 'assistant' && lastMsg.content.length > 0) {
const newAnnotations = parseAnnotationsFromAIResponse(lastMsg.content, activeFileContent)
if (newAnnotations.length > 0) setAnnotations(newAnnotations)
}
} catch (err) {
setAIError(err instanceof Error ? err.message : 'Analysis failed')
} finally {
setAILoading(false)
}
}
const passiveCount = annotations.filter((a) => a.type === 'passive_voice').length
const otherCount = annotations.filter((a) => a.type !== 'passive_voice').length
return (
<div className="toolbar">
<div className="toolbar-left">
<button
className={`toolbar-btn${analysisMode === 'passive_voice' ? ' active' : ''}`}
onClick={runPassiveVoice}
disabled={!hasFile}
title="Highlight passive voice sentences instantly (no AI required)"
>
Passive Voice
{analysisMode === 'passive_voice' && passiveCount > 0 && (
<span className="toolbar-badge">{passiveCount}</span>
)}
</button>
<button
className={`toolbar-btn${analysisMode === 'consistency' ? ' active' : ''}`}
onClick={() => runAIAnalysis('consistency')}
disabled={!hasFile || isAILoading}
title="Check character names, timeline, and repeated phrases via AI"
>
{isAILoading && analysisMode === 'consistency' ? 'Checking…' : 'Consistency'}
{analysisMode === 'consistency' && otherCount > 0 && (
<span className="toolbar-badge">{otherCount}</span>
)}
</button>
<button
className={`toolbar-btn${analysisMode === 'style' ? ' active' : ''}`}
onClick={() => runAIAnalysis('style')}
disabled={!hasFile || isAILoading}
title="Pacing, sentence variety, show-don't-tell feedback via AI"
>
{isAILoading && analysisMode === 'style' ? 'Analyzing…' : 'Style'}
{analysisMode === 'style' && otherCount > 0 && (
<span className="toolbar-badge">{otherCount}</span>
)}
</button>
{annotations.length > 0 && (
<button
className="toolbar-btn toolbar-btn-clear"
onClick={clearAnnotations}
title="Remove all highlights"
>
Clear
</button>
)}
</div>
<div className="toolbar-right">
{isDirty && (
<span className="toolbar-dirty" title="Unsaved changes — press Cmd+S to save">
</span>
)}
{activeFilePath && (
<span className="toolbar-filename">
{activeFilePath.split('/').pop()?.replace(/\.md$/, '')}
</span>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,97 @@
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 12px;
background: var(--toolbar-bg);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
gap: 8px;
min-height: 42px;
}
.toolbar-left {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.toolbar-right {
display: flex;
align-items: center;
gap: 10px;
overflow: hidden;
}
.toolbar-btn {
position: relative;
background: none;
border: 1px solid var(--border);
border-radius: 5px;
color: var(--text-secondary);
font-size: 12px;
font-family: var(--font-sans);
padding: 4px 11px;
cursor: pointer;
transition: color 0.15s, border-color 0.15s, background 0.15s;
white-space: nowrap;
}
.toolbar-btn:hover:not(:disabled) {
color: var(--text-primary);
border-color: var(--text-muted);
}
.toolbar-btn.active {
border-color: var(--accent);
color: var(--accent);
background: rgba(167, 139, 95, 0.1);
}
.toolbar-btn:disabled {
opacity: 0.4;
cursor: default;
}
.toolbar-btn-clear {
border-color: transparent;
color: var(--text-muted);
font-size: 11px;
}
.toolbar-btn-clear:hover:not(:disabled) {
color: var(--text-primary);
border-color: var(--border);
}
.toolbar-badge {
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--accent);
color: #1a1208;
font-size: 10px;
font-weight: 700;
border-radius: 8px;
padding: 0 5px;
min-width: 16px;
height: 16px;
margin-left: 6px;
}
.toolbar-dirty {
color: var(--accent);
font-size: 14px;
flex-shrink: 0;
}
.toolbar-filename {
color: var(--text-muted);
font-size: 11px;
font-family: var(--font-serif);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 260px;
}

12
src/renderer/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hohoff Editor</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>

10
src/renderer/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './styles/global.css'
import App from './App'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>
)

View File

@@ -0,0 +1,119 @@
import { create } from 'zustand'
import type { FileNode, ChatMessage, TextAnnotation, AnalysisMode } from '../types/editor'
interface EditorState {
// File tree
fileTree: FileNode[]
setFileTree: (tree: FileNode[]) => void
// Active file
activeFilePath: string | null
activeFileContent: string
isDirty: boolean
setActiveFile: (path: string, content: string) => void
setContent: (content: string) => void
markSaved: () => void
// Chat - persisted per file path
chatHistoryByFile: Record<string, ChatMessage[]>
chatHistory: ChatMessage[]
isAILoading: boolean
aiError: string | null
addUserMessage: (text: string) => void
startAssistantMessage: () => void
appendToLastAssistantMessage: (chunk: string) => void
setAILoading: (loading: boolean) => void
setAIError: (error: string | null) => void
clearChat: () => void
// Annotations (highlights in editor)
annotations: TextAnnotation[]
setAnnotations: (annotations: TextAnnotation[]) => void
clearAnnotations: () => void
// Analysis mode
analysisMode: AnalysisMode
setAnalysisMode: (mode: AnalysisMode) => void
}
export const useEditorStore = create<EditorState>((set, get) => ({
fileTree: [],
setFileTree: (fileTree) => set({ fileTree }),
activeFilePath: null,
activeFileContent: '',
isDirty: false,
setActiveFile: (path, content) => {
const existing = get().chatHistoryByFile[path] ?? []
set({
activeFilePath: path,
activeFileContent: content,
isDirty: false,
chatHistory: existing,
annotations: [],
analysisMode: 'none'
})
},
setContent: (content) => set({ activeFileContent: content, isDirty: true }),
markSaved: () => set({ isDirty: false }),
chatHistoryByFile: {},
chatHistory: [],
isAILoading: false,
aiError: null,
addUserMessage: (text) => {
const msg: ChatMessage = { id: `user-${Date.now()}`, role: 'user', content: text }
set((s) => {
const history = [...s.chatHistory, msg]
const byFile = s.activeFilePath
? { ...s.chatHistoryByFile, [s.activeFilePath]: history }
: s.chatHistoryByFile
return { chatHistory: history, chatHistoryByFile: byFile }
})
},
startAssistantMessage: () => {
const msg: ChatMessage = { id: `asst-${Date.now()}`, role: 'assistant', content: '' }
set((s) => {
const history = [...s.chatHistory, msg]
const byFile = s.activeFilePath
? { ...s.chatHistoryByFile, [s.activeFilePath]: history }
: s.chatHistoryByFile
return { chatHistory: history, chatHistoryByFile: byFile }
})
},
appendToLastAssistantMessage: (chunk) => {
set((s) => {
const history = [...s.chatHistory]
const last = history[history.length - 1]
if (last?.role === 'assistant') {
history[history.length - 1] = { ...last, content: last.content + chunk }
}
const byFile = s.activeFilePath
? { ...s.chatHistoryByFile, [s.activeFilePath]: history }
: s.chatHistoryByFile
return { chatHistory: history, chatHistoryByFile: byFile }
})
},
setAILoading: (isAILoading) => set({ isAILoading }),
setAIError: (aiError) => set({ aiError }),
clearChat: () => {
set((s) => {
const byFile = s.activeFilePath
? { ...s.chatHistoryByFile, [s.activeFilePath]: [] }
: s.chatHistoryByFile
return { chatHistory: [], chatHistoryByFile: byFile }
})
},
annotations: [],
setAnnotations: (annotations) => set({ annotations }),
clearAnnotations: () => set({ annotations: [], analysisMode: 'none' }),
analysisMode: 'none',
setAnalysisMode: (analysisMode) => set({ analysisMode })
}))

View File

@@ -0,0 +1,31 @@
.app-layout {
display: grid;
grid-template-columns: 220px 1fr 340px;
grid-template-rows: 100vh;
height: 100vh;
overflow: hidden;
}
/* ─── Sidebar ──────────────────────────────────────────────────── */
.sidebar {
background: var(--sidebar-bg);
border-right: 1px solid var(--border);
overflow: hidden;
display: flex;
flex-direction: column;
}
/* ─── Editor area ──────────────────────────────────────────────── */
.editor-area {
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--editor-bg);
}
/* ─── Chat area ────────────────────────────────────────────────── */
.chat-area {
overflow: hidden;
display: flex;
flex-direction: column;
}

View File

@@ -0,0 +1,68 @@
/* ─── Reset ────────────────────────────────────────────────────── */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
/* ─── Design tokens ────────────────────────────────────────────── */
:root {
/* Background layers */
--bg-base: #131110;
--sidebar-bg: #0f0e0c;
--toolbar-bg: #161412;
--editor-bg: #131110;
--message-bg: #1c1a17;
--user-message-bg:#1e1c17;
--input-bg: #1c1a17;
/* Borders */
--border: #2a2620;
--hover-bg: rgba(167,139,95,0.08);
--active-bg: rgba(167,139,95,0.12);
/* Text */
--text-primary: #e8e0d0;
--text-secondary: #b5a990;
--text-muted: #7a6f5e;
/* Heading color in editor */
--heading-color: #d4b896;
/* Accent (aged gold) */
--accent: #a78b5f;
--accent-hover: #c4a870;
/* Typography */
--font-serif: 'Georgia', 'Times New Roman', serif;
--font-sans: -apple-system, 'Segoe UI', Helvetica, Arial, sans-serif;
}
/* ─── Base ─────────────────────────────────────────────────────── */
html, body, #root {
height: 100%;
overflow: hidden;
}
body {
background: var(--bg-base);
color: var(--text-primary);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
}
/* ─── Scrollbars ───────────────────────────────────────────────── */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}

View File

@@ -0,0 +1,36 @@
export interface FileNode {
name: string
path: string
type: 'file' | 'directory'
children?: FileNode[]
}
export interface ChatMessage {
id: string
role: 'user' | 'assistant'
content: string
}
export type AnnotationType = 'passive_voice' | 'consistency' | 'style'
export interface TextAnnotation {
id: string
type: AnnotationType
from: number
to: number
matchedText: string
message: string
suggestion?: string
}
export type AnalysisMode = 'none' | 'passive_voice' | 'consistency' | 'style'
export type AIMode = 'chat' | 'passive_voice' | 'consistency' | 'style'
export interface AIPayload {
mode: AIMode
documentContent: string
documentPath: string
conversationHistory: Array<{ role: 'user' | 'assistant'; content: string }>
userMessage: string
}

16
src/renderer/types/global.d.ts vendored Normal file
View File

@@ -0,0 +1,16 @@
import type { FileNode, AIPayload } from './editor'
declare global {
interface Window {
api: {
listFiles: () => Promise<FileNode[]>
readFile: (filePath: string) => Promise<string>
writeFile: (filePath: string, content: string) => Promise<void>
streamAIMessage: (
payload: AIPayload,
onChunk: (chunk: string) => void
) => Promise<void>
removeAIListener: () => void
}
}
}

View File

@@ -0,0 +1,125 @@
import type { TextAnnotation, AnnotationType } from '../types/editor'
// Attempt to locate AI-quoted text in the document and create highlight annotations
export function parseAnnotationsFromAIResponse(
aiResponse: string,
documentContent: string
): TextAnnotation[] {
const annotations: TextAnnotation[] = []
let id = 0
// Match quoted strings — handles "straight", "curly", and 'single' quotes
// Minimum 10 chars to avoid matching short words
const quotePattern = /["""''](.{10,300}?)["""'']/g
let match: RegExpExecArray | null
while ((match = quotePattern.exec(aiResponse)) !== null) {
const quotedText = match[1].trim()
// Try exact match first
let docIndex = documentContent.indexOf(quotedText)
// If not found exactly, try a normalized version (collapse whitespace)
if (docIndex === -1) {
const normalized = quotedText.replace(/\s+/g, ' ')
docIndex = findNormalized(documentContent, normalized)
}
if (docIndex === -1) continue
// Don't annotate the same range twice
const alreadyAnnotated = annotations.some(
(a) => a.from === docIndex && a.to === docIndex + quotedText.length
)
if (alreadyAnnotated) continue
// Determine annotation type from context around the quote in the AI response
const contextStart = Math.max(0, match.index - 200)
const contextBefore = aiResponse.slice(contextStart, match.index).toLowerCase()
const type = classifyType(contextBefore)
// Try to extract a suggestion from text after the quote
const afterQuote = aiResponse.slice(match.index + match[0].length, match.index + match[0].length + 400)
const suggestion = extractSuggestion(afterQuote)
// Extract a short message label from the ISSUE/PROBLEM line before the quote
const message = extractMessage(aiResponse, match.index)
annotations.push({
id: `ai-${id++}`,
type,
from: docIndex,
to: docIndex + quotedText.length,
matchedText: quotedText,
message: message || `${type.replace('_', ' ')} — hover for details`,
suggestion
})
}
return annotations
}
function classifyType(contextBefore: string): AnnotationType {
if (contextBefore.includes('passive')) return 'passive_voice'
if (
contextBefore.includes('consistency') ||
contextBefore.includes('character') ||
contextBefore.includes('timeline') ||
contextBefore.includes('repeated') ||
contextBefore.includes('contradiction')
) {
return 'consistency'
}
return 'style'
}
function extractSuggestion(text: string): string | undefined {
// Look for SUGGESTION: "..." pattern
const m = text.match(/SUGGESTION:\s*["""'](.{5,200}?)["""']/i)
return m?.[1]?.trim()
}
function extractMessage(response: string, quoteIndex: number): string {
// Look back for ISSUE: or PROBLEM: line
const before = response.slice(Math.max(0, quoteIndex - 300), quoteIndex)
const issueMatch = before.match(/(?:ISSUE|PROBLEM|WHY):\s*(.+?)(?:\n|$)/gi)
if (issueMatch) {
const last = issueMatch[issueMatch.length - 1]
return last.replace(/^(?:ISSUE|PROBLEM|WHY):\s*/i, '').trim().slice(0, 120)
}
// Fall back to the last sentence before the quote
const sentences = before.split(/[.!?]\s+/)
const last = sentences[sentences.length - 1]?.trim()
return last?.slice(0, 120) ?? ''
}
// Find a normalized string in a document (ignores whitespace differences)
function findNormalized(document: string, normalized: string): number {
const words = normalized.split(' ')
if (words.length < 3) return -1
// Search for the first few words as an anchor
const anchor = words.slice(0, 4).join(' ')
let searchFrom = 0
while (searchFrom < document.length) {
const idx = document.indexOf(words[0], searchFrom)
if (idx === -1) break
// Extract a comparable slice from the document
const slice = document.slice(idx, idx + normalized.length * 2).replace(/\s+/g, ' ')
if (slice.startsWith(normalized)) {
return idx
}
// Check if the anchor matches
const docSlice = document.slice(idx, idx + anchor.length + 20).replace(/\s+/g, ' ')
if (docSlice.startsWith(anchor)) {
return idx
}
searchFrom = idx + 1
}
return -1
}

View File

@@ -0,0 +1,88 @@
import type { TextAnnotation } from '../types/editor'
// Common irregular past participles
const IRREGULAR_PP =
'written|known|seen|found|made|done|given|taken|left|told|shown|brought|' +
'felt|kept|held|set|put|become|come|run|begun|gone|sent|built|paid|said|' +
'heard|met|read|lost|won|broken|fallen|grown|drawn|driven|eaten|forgotten|' +
'hidden|ridden|risen|stolen|sworn|thrown|worn|woken|chosen|frozen|gotten|' +
'proven|shaken|spoken|stolen|undertaken|woven|withdrawn|born|caught|bought|' +
'brought|fought|taught|thought|sought|hit|hurt|let|put|cut|shut|split|spread|' +
'led|fed|bled|bred|fled|sped|spun|stung|struck|strung|swung|flung|clung|' +
'rung|sung|slung|hung|dug|dug|stuck|struck|stunk|shrunk|drunk|sunk|sprung'
// Pattern: [to-be form] [optional adverb] [past participle]
// Handles: "was written", "is being known", "were quickly sent"
const PASSIVE_PATTERN = new RegExp(
`\\b(is|was|were|are|been|being|be|am)\\b(\\s+\\w+ly)?\\s+(${IRREGULAR_PP}|\\w+ed)\\b`,
'gi'
)
function findSentenceStart(text: string, pos: number): number {
let i = pos - 1
while (i > 0) {
// Look for sentence-ending punctuation followed by whitespace
if (/[.!?]/.test(text[i]) && i + 1 < text.length && /\s/.test(text[i + 1])) {
return i + 2
}
// Also stop at paragraph breaks
if (text[i] === '\n' && i > 0 && text[i - 1] === '\n') {
return i + 1
}
i--
}
return 0
}
function findSentenceEnd(text: string, pos: number): number {
let i = pos
while (i < text.length) {
if (/[.!?]/.test(text[i])) {
return i + 1
}
if (text[i] === '\n') {
return i
}
i++
}
return text.length
}
export function detectPassiveVoice(text: string): TextAnnotation[] {
const annotations: TextAnnotation[] = []
const seenRanges = new Set<string>()
PASSIVE_PATTERN.lastIndex = 0
let match: RegExpExecArray | null
while ((match = PASSIVE_PATTERN.exec(text)) !== null) {
const matchStart = match.index
const matchEnd = match.index + match[0].length
// Skip if this looks like "has been" (perfect passive is sometimes fine)
// and skip matches inside markdown headers
const lineStart = text.lastIndexOf('\n', matchStart) + 1
const lineText = text.slice(lineStart, matchEnd)
if (lineText.trimStart().startsWith('#')) continue
const from = findSentenceStart(text, matchStart)
const to = findSentenceEnd(text, matchEnd)
const key = `${from}-${to}`
if (seenRanges.has(key)) continue
seenRanges.add(key)
const sentence = text.slice(from, to).trim()
annotations.push({
id: `pv-${matchStart}`,
type: 'passive_voice',
from,
to,
matchedText: sentence,
message: `Passive voice: "${match[0].trim()}"`,
suggestion: undefined
})
}
return annotations
}

7
tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.node.json" },
{ "path": "./tsconfig.web.json" }
]
}

12
tsconfig.node.json Normal file
View File

@@ -0,0 +1,12 @@
{
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
"include": [
"electron.vite.config.*",
"src/main/**/*",
"src/preload/**/*"
],
"compilerOptions": {
"composite": true,
"types": ["electron-vite/node"]
}
}

11
tsconfig.web.json Normal file
View File

@@ -0,0 +1,11 @@
{
"extends": "@electron-toolkit/tsconfig/tsconfig.web.json",
"include": ["src/renderer/**/*"],
"compilerOptions": {
"composite": true,
"baseUrl": ".",
"paths": {
"@renderer/*": ["src/renderer/*"]
}
}
}