Add logs tab with real-time server output and consolidate syntax highlighting

- Implement dedicated Logs tab showing stdout/stderr from OpenCode server
- Add log level parsing (INFO, ERROR, WARN, DEBUG) with color coding
- Stream logs from main process to renderer via IPC events
- Persist scroll position and auto-scroll state per instance
- Synchronize instance IDs between renderer and main process
- Consolidate syntax highlighting to single shared highlighter instance
- Optimize markdown rendering with global highlighter initialization
- Fix code block copy button to always appear on right side
- Enable debug logging with --print-logs --log-level DEBUG flags
This commit is contained in:
Shantur Rathore
2025-10-23 11:14:35 +01:00
parent b836086978
commit 3c5c4755b8
13 changed files with 779 additions and 157 deletions

View File

@@ -6,6 +6,8 @@ import InstanceTabs from "./components/instance-tabs"
import SessionTabs from "./components/session-tabs"
import MessageStream from "./components/message-stream"
import PromptInput from "./components/prompt-input"
import LogsView from "./components/logs-view"
import { initMarkdown } from "./lib/markdown"
import {
hasInstances,
isSelectingFolder,
@@ -23,6 +25,7 @@ import {
setActiveInstanceId,
stopInstance,
getActiveInstance,
addLog,
} from "./stores/instances"
import {
getSessions,
@@ -173,6 +176,8 @@ const App: Component = () => {
}
onMount(() => {
initMarkdown(false).catch(console.error)
setupTabKeyboardShortcuts(handleSelectFolder, handleNewSession, handleCloseSession)
window.electronAPI.onNewInstance(() => {
@@ -193,6 +198,10 @@ const App: Component = () => {
console.log("Instance stopped:", id)
updateInstance(id, { status: "stopped" })
})
window.electronAPI.onInstanceLog(({ id, entry }) => {
addLog(id, entry)
})
})
return (
@@ -255,10 +264,7 @@ const App: Component = () => {
</Show>
}
>
<div class="p-4 text-gray-600">
<p class="font-semibold mb-2">Server Logs</p>
<p class="text-sm">Log viewer will be implemented in Task 013</p>
</div>
<LogsView instanceId={instance().id} />
</Show>
</div>
</Show>

View File

@@ -1,67 +1,42 @@
import { createSignal, onMount, Show } from "solid-js"
import { getHighlighter, type Highlighter } from "shiki"
import { createSignal, onMount, Show, createEffect } from "solid-js"
import type { Highlighter } from "shiki"
import { useTheme } from "../lib/theme"
import { getSharedHighlighter, escapeHtml } from "../lib/markdown"
interface CodeBlockInlineProps {
code: string
language?: string
}
let highlighter: Highlighter | null = null
async function getOrCreateHighlighter() {
if (!highlighter) {
highlighter = await getHighlighter({
themes: ["github-light", "github-dark"],
langs: [
"typescript",
"javascript",
"python",
"bash",
"json",
"html",
"css",
"markdown",
"yaml",
"sql",
"rust",
"go",
"cpp",
"c",
"java",
"csharp",
"php",
"ruby",
"swift",
"kotlin",
"diff",
"shell",
],
})
}
return highlighter
}
export function CodeBlockInline(props: CodeBlockInlineProps) {
const { isDark } = useTheme()
const [html, setHtml] = createSignal("")
const [copied, setCopied] = createSignal(false)
const [ready, setReady] = createSignal(false)
let highlighter: Highlighter | null = null
onMount(async () => {
const hl = await getOrCreateHighlighter()
highlighter = await getSharedHighlighter()
setReady(true)
updateHighlight(hl)
updateHighlight()
})
const updateHighlight = async (hl: Highlighter) => {
createEffect(() => {
if (ready()) {
updateHighlight()
}
})
const updateHighlight = () => {
if (!highlighter) return
if (!props.language) {
setHtml(`<pre><code>${escapeHtml(props.code)}</code></pre>`)
return
}
try {
const highlighted = hl.codeToHtml(props.code, {
const highlighted = highlighter.codeToHtml(props.code, {
lang: props.language,
theme: isDark() ? "github-dark" : "github-light",
})
@@ -116,14 +91,3 @@ export function CodeBlockInline(props: CodeBlockInlineProps) {
</Show>
)
}
function escapeHtml(text: string): string {
const map: Record<string, string> = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#039;",
}
return text.replace(/[&<>"']/g, (m) => map[m])
}

View File

@@ -0,0 +1,118 @@
import { Component, For, createSignal, createEffect, Show, onMount, onCleanup } from "solid-js"
import { instances } from "../stores/instances"
import { Trash2, ChevronDown } from "lucide-solid"
import type { LogEntry } from "../types/instance"
interface LogsViewProps {
instanceId: string
}
const logsScrollState = new Map<string, { scrollTop: number; autoScroll: boolean }>()
const LogsView: Component<LogsViewProps> = (props) => {
let scrollRef: HTMLDivElement | undefined
const savedState = logsScrollState.get(props.instanceId)
const [autoScroll, setAutoScroll] = createSignal(savedState?.autoScroll ?? false)
const instance = () => instances().get(props.instanceId)
const logs = () => instance()?.logs ?? []
onMount(() => {
if (scrollRef && savedState) {
scrollRef.scrollTop = savedState.scrollTop
}
})
onCleanup(() => {
if (scrollRef) {
logsScrollState.set(props.instanceId, {
scrollTop: scrollRef.scrollTop,
autoScroll: autoScroll(),
})
}
})
createEffect(() => {
if (autoScroll() && scrollRef && logs().length > 0) {
scrollRef.scrollTop = scrollRef.scrollHeight
}
})
const handleScroll = () => {
if (!scrollRef) return
const isAtBottom = scrollRef.scrollHeight - scrollRef.scrollTop <= scrollRef.clientHeight + 50
setAutoScroll(isAtBottom)
}
const scrollToBottom = () => {
if (scrollRef) {
scrollRef.scrollTop = scrollRef.scrollHeight
setAutoScroll(true)
}
}
const formatTime = (timestamp: number) => {
const date = new Date(timestamp)
return date.toLocaleTimeString("en-US", {
hour12: false,
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
})
}
const getLevelColor = (level: string) => {
switch (level) {
case "error":
return "text-red-600 dark:text-red-400"
case "warn":
return "text-yellow-600 dark:text-yellow-400"
case "debug":
return "text-gray-500 dark:text-gray-500"
default:
return "text-gray-900 dark:text-gray-100"
}
}
return (
<div class="flex flex-col h-full bg-white dark:bg-gray-900">
<div class="flex items-center justify-between px-4 py-2 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300">Server Logs</h3>
</div>
<div
ref={scrollRef}
onScroll={handleScroll}
class="flex-1 overflow-y-auto p-4 bg-gray-50 dark:bg-gray-900 font-mono text-xs leading-relaxed"
>
<Show
when={logs().length > 0}
fallback={<div class="text-gray-500 dark:text-gray-500 text-center py-8">Waiting for server output...</div>}
>
<For each={logs()}>
{(entry) => (
<div class="flex gap-3 py-0.5 hover:bg-gray-100 dark:hover:bg-gray-800 px-2 -mx-2 rounded">
<span class="text-gray-500 dark:text-gray-500 select-none shrink-0">{formatTime(entry.timestamp)}</span>
<span class={`${getLevelColor(entry.level)} break-all`}>{entry.message}</span>
</div>
)}
</For>
</Show>
</div>
<Show when={!autoScroll()}>
<button
onClick={scrollToBottom}
class="absolute bottom-6 right-6 px-3 py-2 bg-blue-600 text-white rounded-full shadow-lg hover:bg-blue-700 flex items-center gap-1 text-sm"
>
<ChevronDown class="w-4 h-4" />
Scroll to bottom
</button>
</Show>
</div>
)
}
export default LogsView

View File

@@ -1,5 +1,5 @@
import { createEffect, createSignal, onMount, Show } from "solid-js"
import { initMarkdown, renderMarkdown } from "../lib/markdown"
import { createEffect, createSignal, Show } from "solid-js"
import { renderMarkdown } from "../lib/markdown"
interface MarkdownProps {
content: string
@@ -8,29 +8,11 @@ interface MarkdownProps {
export function Markdown(props: MarkdownProps) {
const [html, setHtml] = createSignal("")
const [ready, setReady] = createSignal(false)
let containerRef: HTMLDivElement | undefined
onMount(async () => {
await initMarkdown(props.isDark ?? false)
setReady(true)
})
createEffect(async () => {
if (ready()) {
const rendered = await renderMarkdown(props.content)
setHtml(rendered)
}
})
createEffect(async () => {
if (props.isDark !== undefined) {
await initMarkdown(props.isDark)
if (ready()) {
const rendered = await renderMarkdown(props.content)
setHtml(rendered)
}
}
const rendered = await renderMarkdown(props.content)
setHtml(rendered)
})
createEffect(() => {
@@ -87,9 +69,5 @@ export function Markdown(props: MarkdownProps) {
}
})
return (
<Show when={ready()} fallback={<div class="text-gray-500">Loading...</div>}>
<div ref={containerRef} class="prose prose-sm dark:prose-invert max-w-none" innerHTML={html()} />
</Show>
)
return <div ref={containerRef} class="prose prose-sm dark:prose-invert max-w-none" innerHTML={html()} />
}

View File

@@ -948,6 +948,7 @@ body {
cursor: pointer;
color: #666;
transition: all 150ms ease;
margin-left: auto;
}
[data-theme="dark"] .code-block-copy {

View File

@@ -2,43 +2,32 @@ import { marked } from "marked"
import { getHighlighter, type Highlighter } from "shiki"
let highlighter: Highlighter | null = null
let highlighterPromise: Promise<Highlighter> | null = null
let currentTheme: "light" | "dark" = "light"
let isInitialized = false
async function getOrCreateHighlighter() {
if (!highlighter) {
highlighter = await getHighlighter({
themes: ["github-light", "github-dark"],
langs: [
"typescript",
"javascript",
"python",
"bash",
"json",
"html",
"css",
"markdown",
"yaml",
"sql",
"rust",
"go",
"cpp",
"c",
"java",
"csharp",
"php",
"ruby",
"swift",
"kotlin",
"diff",
"shell",
],
})
if (highlighter) {
return highlighter
}
if (highlighterPromise) {
return highlighterPromise
}
highlighterPromise = getHighlighter({
themes: ["github-light", "github-dark"],
langs: [],
})
highlighter = await highlighterPromise
highlighterPromise = null
return highlighter
}
export async function initMarkdown(isDark: boolean) {
const hl = await getOrCreateHighlighter()
function setupRenderer(isDark: boolean) {
if (!highlighter) return
currentTheme = isDark ? "dark" : "light"
marked.setOptions({
@@ -52,12 +41,12 @@ export async function initMarkdown(isDark: boolean) {
const encodedCode = encodeURIComponent(code)
const escapedLang = lang ? escapeHtml(lang) : ""
if (!lang) {
if (!lang || !highlighter) {
return `<div class="markdown-code-block" data-language="" data-code="${encodedCode}"><pre><code>${escapeHtml(code)}</code></pre></div>`
}
try {
const html = hl.codeToHtml(code, {
const html = highlighter.codeToHtml(code, {
lang,
theme: isDark ? "github-dark" : "github-light",
})
@@ -79,14 +68,28 @@ export async function initMarkdown(isDark: boolean) {
marked.use({ renderer })
}
export async function initMarkdown(isDark: boolean) {
await getOrCreateHighlighter()
setupRenderer(isDark)
isInitialized = true
}
export function isMarkdownReady(): boolean {
return isInitialized && highlighter !== null
}
export async function renderMarkdown(content: string): Promise<string> {
if (!highlighter) {
if (!isInitialized) {
await initMarkdown(currentTheme === "dark")
}
return marked.parse(content) as Promise<string>
}
function escapeHtml(text: string): string {
export async function getSharedHighlighter(): Promise<Highlighter> {
return getOrCreateHighlighter()
}
export function escapeHtml(text: string): string {
const map: Record<string, string> = {
"&": "&amp;",
"<": "&lt;",

View File

@@ -1,5 +1,5 @@
import { createSignal } from "solid-js"
import type { Instance } from "../types/instance"
import type { Instance, LogEntry } from "../types/instance"
import { sdkManager } from "../lib/sdk-manager"
import { sseManager } from "../lib/sse-manager"
import { fetchSessions, fetchAgents, fetchProviders } from "./sessions"
@@ -8,6 +8,8 @@ import { showSessionPicker } from "./ui"
const [instances, setInstances] = createSignal<Map<string, Instance>>(new Map())
const [activeInstanceId, setActiveInstanceId] = createSignal<string | null>(null)
const MAX_LOG_ENTRIES = 1000
function addInstance(instance: Instance) {
setInstances((prev) => {
const next = new Map(prev)
@@ -40,48 +42,48 @@ function removeInstance(id: string) {
}
async function createInstance(folder: string): Promise<string> {
const tempId = `temp-${Date.now()}`
const id = `instance-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
const instance: Instance = {
id: tempId,
id,
folder,
port: 0,
pid: 0,
status: "starting",
client: null,
logs: [],
}
addInstance(instance)
try {
const { port, pid } = await window.electronAPI.createInstance(folder)
const { id: returnedId, port, pid } = await window.electronAPI.createInstance(id, folder)
const client = sdkManager.createClient(port)
updateInstance(tempId, {
updateInstance(id, {
port,
pid,
client,
status: "ready",
})
setActiveInstanceId(tempId)
sseManager.connect(tempId, port)
setActiveInstanceId(id)
sseManager.connect(id, port)
try {
await fetchSessions(tempId)
await fetchAgents(tempId)
await fetchProviders(tempId)
await fetchSessions(id)
await fetchAgents(id)
await fetchProviders(id)
} catch (error) {
console.error("Failed to fetch initial data:", error)
}
showSessionPicker(tempId)
showSessionPicker(id)
return tempId
return id
} catch (error) {
updateInstance(tempId, {
updateInstance(id, {
status: "error",
error: error instanceof Error ? error.message : String(error),
})
@@ -111,6 +113,32 @@ function getActiveInstance(): Instance | null {
return id ? instances().get(id) || null : null
}
function addLog(id: string, entry: LogEntry) {
setInstances((prev) => {
const next = new Map(prev)
const instance = next.get(id)
if (instance) {
const logs = [...instance.logs, entry]
if (logs.length > MAX_LOG_ENTRIES) {
logs.shift()
}
next.set(id, { ...instance, logs })
}
return next
})
}
function clearLogs(id: string) {
setInstances((prev) => {
const next = new Map(prev)
const instance = next.get(id)
if (instance) {
next.set(id, { ...instance, logs: [] })
}
return next
})
}
export {
instances,
activeInstanceId,
@@ -121,4 +149,6 @@ export {
createInstance,
stopInstance,
getActiveInstance,
addLog,
clearLogs,
}

View File

@@ -1,5 +1,11 @@
import type { OpencodeClient } from "@opencode-ai/sdk/client"
export interface LogEntry {
timestamp: number
level: "info" | "error" | "warn" | "debug"
message: string
}
export interface Instance {
id: string
folder: string
@@ -8,10 +14,5 @@ export interface Instance {
status: "starting" | "ready" | "error" | "stopped"
error?: string
client: OpencodeClient | null
}
export interface LogEntry {
timestamp: number
level: "info" | "error"
message: string
logs: LogEntry[]
}