diff --git a/claude_cli.go b/claude_cli.go index 733d675..6a071bd 100644 --- a/claude_cli.go +++ b/claude_cli.go @@ -29,8 +29,10 @@ type CLIEvent struct { RawResult json.RawMessage `json:"result,omitempty"` Result *CLIResult `json:"-"` - // Top-level cost field (present when result is string) + // Top-level fields on result events TotalCostUSD float64 `json:"total_cost_usd,omitempty"` + DurationMS float64 `json:"duration_ms,omitempty"` + NumTurns int `json:"num_turns,omitempty"` } // StreamEvent is the inner event inside a stream_event wrapper. @@ -213,14 +215,25 @@ func (cp *CLIProcess) readOutput() { } // Parse result field — can be string or object - if len(event.RawResult) > 0 { - var result CLIResult - if err := json.Unmarshal(event.RawResult, &result); err == nil { - event.Result = &result + if event.Type == "result" { + if len(event.RawResult) > 0 { + var result CLIResult + if err := json.Unmarshal(event.RawResult, &result); err == nil { + event.Result = &result + } } - // If it's a string, Result stays nil — cost comes from top-level field - if event.Result == nil && event.TotalCostUSD > 0 { - event.Result = &CLIResult{CostUSD: event.TotalCostUSD} + // Always build Result from top-level fields (they're always present on result events) + if event.Result == nil { + event.Result = &CLIResult{} + } + if event.TotalCostUSD > 0 { + event.Result.CostUSD = event.TotalCostUSD + } + if event.DurationMS > 0 { + event.Result.Duration = event.DurationMS + } + if event.NumTurns > 0 { + event.Result.NumTurns = event.NumTurns } } diff --git a/files.go b/files.go index 009e7dd..2f035b9 100644 --- a/files.go +++ b/files.go @@ -1,14 +1,11 @@ package main import ( - "bytes" "fmt" "os" "path/filepath" "sort" "strings" - - "github.com/yuin/goldmark" ) type FileInfo struct { @@ -102,10 +99,5 @@ func RenderMarkdownFile(projectDir, relPath string) (string, error) { return "", err } - var buf bytes.Buffer - if err := goldmark.Convert([]byte(content), &buf); err != nil { - return "", fmt.Errorf("render markdown: %w", err) - } - - return buf.String(), nil + return RenderMD(content), nil } diff --git a/markdown.go b/markdown.go new file mode 100644 index 0000000..98eaf55 --- /dev/null +++ b/markdown.go @@ -0,0 +1,29 @@ +package main + +import ( + "bytes" + + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/extension" +) + +var md goldmark.Markdown + +func init() { + md = goldmark.New( + goldmark.WithExtensions( + extension.Table, + extension.Strikethrough, + extension.TaskList, + ), + ) +} + +// RenderMD converts markdown text to HTML with table support. +func RenderMD(text string) string { + var buf bytes.Buffer + if err := md.Convert([]byte(text), &buf); err != nil { + return text + } + return buf.String() +} diff --git a/static/style.css b/static/style.css index 85bdff0..5c0b6b2 100644 --- a/static/style.css +++ b/static/style.css @@ -178,14 +178,14 @@ a:hover { font-size: 0.85rem; } -/* Chat layout */ +/* Chat layout — terminal style */ .chat-container { display: flex; height: 100vh; } .sidebar { - width: 300px; + width: 280px; background: var(--bg-secondary); border-right: 1px solid var(--border); display: flex; @@ -194,7 +194,7 @@ a:hover { } .sidebar-header { - padding: 1rem; + padding: 0.75rem 1rem; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; @@ -202,7 +202,7 @@ a:hover { } .sidebar-header h3 { - font-size: 0.95rem; + font-size: 0.9rem; color: var(--text-secondary); } @@ -213,10 +213,11 @@ a:hover { } .file-item { - padding: 0.5rem 0.75rem; - border-radius: 6px; + padding: 0.4rem 0.75rem; + border-radius: 4px; cursor: pointer; - font-size: 0.85rem; + font-size: 0.8rem; + font-family: 'JetBrains Mono', 'Fira Code', monospace; color: var(--text-secondary); display: block; text-decoration: none; @@ -228,33 +229,33 @@ a:hover { color: var(--text-primary); } -.file-item.active { - background: var(--bg-tertiary); - color: var(--accent); -} - .chat-main { flex: 1; display: flex; flex-direction: column; min-width: 0; + background: #0d1117; } .chat-header { - padding: 0.75rem 1.5rem; + padding: 0.5rem 1rem; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; background: var(--bg-secondary); + font-family: 'JetBrains Mono', 'Fira Code', monospace; + font-size: 0.85rem; } .chat-header h2 { - font-size: 1.1rem; + font-size: 0.9rem; + font-weight: normal; + color: var(--accent); } .chat-header .status { - font-size: 0.8rem; + font-size: 0.75rem; color: var(--text-muted); } @@ -262,69 +263,82 @@ a:hover { color: var(--success); } +/* Terminal output area */ .chat-messages { flex: 1; overflow-y: auto; - padding: 1rem 1.5rem; - display: flex; - flex-direction: column; - gap: 1rem; + padding: 0.75rem 1rem; + font-family: 'JetBrains Mono', 'Fira Code', monospace; + font-size: 0.85rem; + line-height: 1.6; } +/* User input — prompt style */ .message { - max-width: 85%; - padding: 0.8rem 1.2rem; - border-radius: 12px; - line-height: 1.5; - font-size: 0.95rem; + padding: 0.3rem 0; word-wrap: break-word; + max-width: 100%; } .message-user { - align-self: flex-end; - background: var(--bg-tertiary); - border: 1px solid var(--border); -} - -.message-assistant { - align-self: flex-start; - background: var(--bg-secondary); - border: 1px solid var(--border); -} - -.message-assistant .content { + color: var(--success); white-space: pre-wrap; } -.message-system { - align-self: center; - background: transparent; - color: var(--text-muted); - font-size: 0.85rem; - font-style: italic; +.message-user::before { + content: "❯ "; + color: var(--accent); + font-weight: bold; } +/* Claude response */ +.message-assistant { + color: var(--text-primary); + padding: 0.4rem 0; + border-left: 2px solid var(--border); + padding-left: 0.75rem; + margin: 0.3rem 0; +} + +.message-assistant .content { + /* rendered markdown, no pre-wrap */ +} + +/* System messages */ +.message-system { + color: var(--text-muted); + font-size: 0.75rem; + padding: 0.15rem 0; +} + +/* Tool calls — command style */ .message-tool { - align-self: flex-start; - background: rgba(15, 52, 96, 0.5); - border: 1px solid var(--border); - font-size: 0.85rem; color: var(--text-secondary); - max-width: 90%; + font-size: 0.8rem; + padding: 0.2rem 0; + opacity: 0.7; } .message-tool .tool-name { color: var(--warning); font-weight: 600; - margin-bottom: 0.3rem; + display: inline; } +.message-tool .tool-name::before { + content: "⚙ "; +} + +.message-tool div { + display: inline; +} + +/* Typing indicator */ .typing-indicator { - align-self: flex-start; color: var(--text-muted); - font-style: italic; - font-size: 0.85rem; - padding: 0.5rem 0; + font-size: 0.8rem; + padding: 0.2rem 0; + font-family: 'JetBrains Mono', 'Fira Code', monospace; } .typing-indicator .dots { @@ -338,29 +352,31 @@ a:hover { 80%, 100% { opacity: 1; } } +/* Input area — terminal prompt */ .chat-input-area { - padding: 1rem 1.5rem; + padding: 0.5rem 1rem; border-top: 1px solid var(--border); - background: var(--bg-secondary); + background: #0d1117; } .chat-input-form { display: flex; - gap: 0.75rem; + gap: 0.5rem; + align-items: flex-end; } .chat-input { flex: 1; - padding: 0.75rem 1rem; - background: var(--bg-input); + padding: 0.5rem 0.75rem; + background: transparent; border: 1px solid var(--border); - border-radius: 8px; + border-radius: 4px; color: var(--text-primary); - font-size: 1rem; + font-size: 0.85rem; + font-family: 'JetBrains Mono', 'Fira Code', monospace; outline: none; resize: none; - font-family: inherit; - min-height: 44px; + min-height: 36px; max-height: 200px; } @@ -369,28 +385,87 @@ a:hover { } .chat-input-form .btn { - align-self: flex-end; -} - -/* Code blocks in messages */ -.message pre { - background: var(--code-bg); - border-radius: 6px; - padding: 0.8rem; - overflow-x: auto; - margin: 0.5rem 0; - font-size: 0.85rem; -} - -.message code { + padding: 0.5rem 1rem; + font-size: 0.8rem; font-family: 'JetBrains Mono', 'Fira Code', monospace; - font-size: 0.9em; } -.message p code { - background: var(--code-bg); - padding: 0.15rem 0.4rem; +/* Markdown rendered content */ +.message-assistant pre { + background: #161b22; + border: 1px solid var(--border); border-radius: 4px; + padding: 0.6rem; + overflow-x: auto; + margin: 0.4rem 0; + font-size: 0.8rem; +} + +.message-assistant code { + font-family: 'JetBrains Mono', 'Fira Code', monospace; + font-size: 0.85em; +} + +.message-assistant p code { + background: #161b22; + padding: 0.1rem 0.3rem; + border-radius: 3px; + font-size: 0.85em; +} + +.message-assistant h1, +.message-assistant h2, +.message-assistant h3 { + color: var(--accent); + margin: 0.6rem 0 0.3rem; + font-size: 1em; +} + +.message-assistant h1 { font-size: 1.1em; } +.message-assistant h2 { font-size: 1em; } + +.message-assistant ul, +.message-assistant ol { + padding-left: 1.5rem; + margin: 0.3rem 0; +} + +.message-assistant p { + margin: 0.3rem 0; +} + +.message-assistant strong { + color: #fff; +} + +/* Tables */ +.message-assistant table, +.file-viewer-content table { + border-collapse: collapse; + margin: 0.5rem 0; + font-size: 0.8rem; + width: auto; +} + +.message-assistant th, +.message-assistant td, +.file-viewer-content th, +.file-viewer-content td { + border: 1px solid var(--border); + padding: 0.3rem 0.6rem; + text-align: left; +} + +.message-assistant th, +.file-viewer-content th { + background: #161b22; + color: var(--accent); + font-weight: 600; +} + +.message-assistant tr:nth-child(even), +.file-viewer-content tr:nth-child(even) { + background: rgba(22, 27, 34, 0.5); } /* File viewer overlay */ @@ -400,7 +475,7 @@ a:hover { right: 0; width: 50%; height: 100vh; - background: var(--bg-secondary); + background: #0d1117; border-left: 1px solid var(--border); z-index: 100; display: flex; @@ -409,31 +484,35 @@ a:hover { } .file-viewer-header { - padding: 1rem; + padding: 0.75rem 1rem; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; + font-family: 'JetBrains Mono', 'Fira Code', monospace; + font-size: 0.85rem; } .file-viewer-content { flex: 1; overflow-y: auto; - padding: 1.5rem; + padding: 1rem 1.5rem; line-height: 1.6; + font-size: 0.9rem; } .file-viewer-content h1, .file-viewer-content h2, .file-viewer-content h3 { - margin: 1rem 0 0.5rem; + margin: 0.8rem 0 0.4rem; color: var(--accent); } .file-viewer-content pre { - background: var(--code-bg); - border-radius: 6px; - padding: 1rem; + background: #161b22; + border: 1px solid var(--border); + border-radius: 4px; + padding: 0.8rem; overflow-x: auto; } @@ -441,18 +520,22 @@ a:hover { font-family: 'JetBrains Mono', 'Fira Code', monospace; } +.file-viewer-content p { + margin: 0.4rem 0; +} + /* Scrollbar */ ::-webkit-scrollbar { - width: 8px; + width: 6px; } ::-webkit-scrollbar-track { - background: var(--bg-primary); + background: #0d1117; } ::-webkit-scrollbar-thumb { background: var(--border); - border-radius: 4px; + border-radius: 3px; } ::-webkit-scrollbar-thumb:hover { diff --git a/ws.go b/ws.go index 16c825c..9a9f1ea 100644 --- a/ws.go +++ b/ws.go @@ -1,7 +1,6 @@ package main import ( - "bytes" "encoding/json" "fmt" "log" @@ -11,7 +10,6 @@ import ( "time" "github.com/gorilla/websocket" - "github.com/yuin/goldmark" ) var upgrader = websocket.Upgrader{ @@ -228,13 +226,30 @@ func (wh *WSHandler) listenEvents(sess *ChatSession) { Content: fragment, Timestamp: time.Now(), }) - if event.Result != nil && event.Result.CostUSD > 0 { - costMsg := FragmentSystemMessage(fmt.Sprintf("Gotovo (%.4f USD)", event.Result.CostUSD)) - sess.AddMessage(ChatMessage{ - Role: "system", - Content: costMsg, - Timestamp: time.Now(), - }) + if event.Result != nil { + parts := []string{} + if event.Result.Duration > 0 { + secs := event.Result.Duration / 1000 + if secs >= 60 { + parts = append(parts, fmt.Sprintf("%.0fm %.0fs", secs/60, float64(int(secs)%60))) + } else { + parts = append(parts, fmt.Sprintf("%.1fs", secs)) + } + } + if event.Result.CostUSD > 0 { + parts = append(parts, fmt.Sprintf("$%.4f", event.Result.CostUSD)) + } + if event.Result.NumTurns > 0 { + parts = append(parts, fmt.Sprintf("%d turn(s)", event.Result.NumTurns)) + } + if len(parts) > 0 { + costMsg := FragmentSystemMessage(strings.Join(parts, " · ")) + sess.AddMessage(ChatMessage{ + Role: "system", + Content: costMsg, + Timestamp: time.Now(), + }) + } } } @@ -274,9 +289,5 @@ func (wh *WSHandler) handleStreamEvent(sess *ChatSession, se *StreamEvent, curre } func renderMarkdown(text string) string { - var buf bytes.Buffer - if err := goldmark.Convert([]byte(text), &buf); err != nil { - return text - } - return buf.String() + return RenderMD(text) }