🎉 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

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
}