Terminal UI stil, markdown tabele i prikaz troškova
All checks were successful
Tests / unit-tests (push) Successful in 8s

- Dodat goldmark sa Table/Strikethrough/TaskList ekstenzijama (markdown.go)
- Prepisana CSS tema na konzolni stil (JetBrains Mono, tamna pozadina, prompt prefix)
- Prikaz troškova i trajanja posle svakog Claude odgovora (duration, cost, turns)
- Ispravljen parsing result eventa (json.RawMessage + top-level polja)
- Ispravljen concurrent write bug na WebSocket (write channel pattern)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
djuka 2026-02-18 05:38:47 +00:00
parent 9d0e507689
commit 0ce03c27e3
5 changed files with 248 additions and 120 deletions

View File

@ -29,8 +29,10 @@ type CLIEvent struct {
RawResult json.RawMessage `json:"result,omitempty"` RawResult json.RawMessage `json:"result,omitempty"`
Result *CLIResult `json:"-"` 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"` 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. // 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 // Parse result field — can be string or object
if event.Type == "result" {
if len(event.RawResult) > 0 { if len(event.RawResult) > 0 {
var result CLIResult var result CLIResult
if err := json.Unmarshal(event.RawResult, &result); err == nil { if err := json.Unmarshal(event.RawResult, &result); err == nil {
event.Result = &result event.Result = &result
} }
// If it's a string, Result stays nil — cost comes from top-level field }
if event.Result == nil && event.TotalCostUSD > 0 { // Always build Result from top-level fields (they're always present on result events)
event.Result = &CLIResult{CostUSD: event.TotalCostUSD} 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
} }
} }

View File

@ -1,14 +1,11 @@
package main package main
import ( import (
"bytes"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"sort" "sort"
"strings" "strings"
"github.com/yuin/goldmark"
) )
type FileInfo struct { type FileInfo struct {
@ -102,10 +99,5 @@ func RenderMarkdownFile(projectDir, relPath string) (string, error) {
return "", err return "", err
} }
var buf bytes.Buffer return RenderMD(content), nil
if err := goldmark.Convert([]byte(content), &buf); err != nil {
return "", fmt.Errorf("render markdown: %w", err)
}
return buf.String(), nil
} }

29
markdown.go Normal file
View File

@ -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()
}

View File

@ -178,14 +178,14 @@ a:hover {
font-size: 0.85rem; font-size: 0.85rem;
} }
/* Chat layout */ /* Chat layout — terminal style */
.chat-container { .chat-container {
display: flex; display: flex;
height: 100vh; height: 100vh;
} }
.sidebar { .sidebar {
width: 300px; width: 280px;
background: var(--bg-secondary); background: var(--bg-secondary);
border-right: 1px solid var(--border); border-right: 1px solid var(--border);
display: flex; display: flex;
@ -194,7 +194,7 @@ a:hover {
} }
.sidebar-header { .sidebar-header {
padding: 1rem; padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@ -202,7 +202,7 @@ a:hover {
} }
.sidebar-header h3 { .sidebar-header h3 {
font-size: 0.95rem; font-size: 0.9rem;
color: var(--text-secondary); color: var(--text-secondary);
} }
@ -213,10 +213,11 @@ a:hover {
} }
.file-item { .file-item {
padding: 0.5rem 0.75rem; padding: 0.4rem 0.75rem;
border-radius: 6px; border-radius: 4px;
cursor: pointer; cursor: pointer;
font-size: 0.85rem; font-size: 0.8rem;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
color: var(--text-secondary); color: var(--text-secondary);
display: block; display: block;
text-decoration: none; text-decoration: none;
@ -228,33 +229,33 @@ a:hover {
color: var(--text-primary); color: var(--text-primary);
} }
.file-item.active {
background: var(--bg-tertiary);
color: var(--accent);
}
.chat-main { .chat-main {
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-width: 0; min-width: 0;
background: #0d1117;
} }
.chat-header { .chat-header {
padding: 0.75rem 1.5rem; padding: 0.5rem 1rem;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
background: var(--bg-secondary); background: var(--bg-secondary);
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 0.85rem;
} }
.chat-header h2 { .chat-header h2 {
font-size: 1.1rem; font-size: 0.9rem;
font-weight: normal;
color: var(--accent);
} }
.chat-header .status { .chat-header .status {
font-size: 0.8rem; font-size: 0.75rem;
color: var(--text-muted); color: var(--text-muted);
} }
@ -262,69 +263,82 @@ a:hover {
color: var(--success); color: var(--success);
} }
/* Terminal output area */
.chat-messages { .chat-messages {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
padding: 1rem 1.5rem; padding: 0.75rem 1rem;
display: flex; font-family: 'JetBrains Mono', 'Fira Code', monospace;
flex-direction: column; font-size: 0.85rem;
gap: 1rem; line-height: 1.6;
} }
/* User input — prompt style */
.message { .message {
max-width: 85%; padding: 0.3rem 0;
padding: 0.8rem 1.2rem;
border-radius: 12px;
line-height: 1.5;
font-size: 0.95rem;
word-wrap: break-word; word-wrap: break-word;
max-width: 100%;
} }
.message-user { .message-user {
align-self: flex-end; color: var(--success);
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 {
white-space: pre-wrap; white-space: pre-wrap;
} }
.message-system { .message-user::before {
align-self: center; content: " ";
background: transparent; color: var(--accent);
color: var(--text-muted); font-weight: bold;
font-size: 0.85rem;
font-style: italic;
} }
/* 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 { .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); color: var(--text-secondary);
max-width: 90%; font-size: 0.8rem;
padding: 0.2rem 0;
opacity: 0.7;
} }
.message-tool .tool-name { .message-tool .tool-name {
color: var(--warning); color: var(--warning);
font-weight: 600; font-weight: 600;
margin-bottom: 0.3rem; display: inline;
} }
.message-tool .tool-name::before {
content: "⚙ ";
}
.message-tool div {
display: inline;
}
/* Typing indicator */
.typing-indicator { .typing-indicator {
align-self: flex-start;
color: var(--text-muted); color: var(--text-muted);
font-style: italic; font-size: 0.8rem;
font-size: 0.85rem; padding: 0.2rem 0;
padding: 0.5rem 0; font-family: 'JetBrains Mono', 'Fira Code', monospace;
} }
.typing-indicator .dots { .typing-indicator .dots {
@ -338,29 +352,31 @@ a:hover {
80%, 100% { opacity: 1; } 80%, 100% { opacity: 1; }
} }
/* Input area — terminal prompt */
.chat-input-area { .chat-input-area {
padding: 1rem 1.5rem; padding: 0.5rem 1rem;
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
background: var(--bg-secondary); background: #0d1117;
} }
.chat-input-form { .chat-input-form {
display: flex; display: flex;
gap: 0.75rem; gap: 0.5rem;
align-items: flex-end;
} }
.chat-input { .chat-input {
flex: 1; flex: 1;
padding: 0.75rem 1rem; padding: 0.5rem 0.75rem;
background: var(--bg-input); background: transparent;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 8px; border-radius: 4px;
color: var(--text-primary); color: var(--text-primary);
font-size: 1rem; font-size: 0.85rem;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
outline: none; outline: none;
resize: none; resize: none;
font-family: inherit; min-height: 36px;
min-height: 44px;
max-height: 200px; max-height: 200px;
} }
@ -369,28 +385,87 @@ a:hover {
} }
.chat-input-form .btn { .chat-input-form .btn {
align-self: flex-end; padding: 0.5rem 1rem;
} font-size: 0.8rem;
/* 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 {
font-family: 'JetBrains Mono', 'Fira Code', monospace; font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 0.9em;
} }
.message p code { /* Markdown rendered content */
background: var(--code-bg); .message-assistant pre {
padding: 0.15rem 0.4rem; background: #161b22;
border: 1px solid var(--border);
border-radius: 4px; 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 */ /* File viewer overlay */
@ -400,7 +475,7 @@ a:hover {
right: 0; right: 0;
width: 50%; width: 50%;
height: 100vh; height: 100vh;
background: var(--bg-secondary); background: #0d1117;
border-left: 1px solid var(--border); border-left: 1px solid var(--border);
z-index: 100; z-index: 100;
display: flex; display: flex;
@ -409,31 +484,35 @@ a:hover {
} }
.file-viewer-header { .file-viewer-header {
padding: 1rem; padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 0.85rem;
} }
.file-viewer-content { .file-viewer-content {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
padding: 1.5rem; padding: 1rem 1.5rem;
line-height: 1.6; line-height: 1.6;
font-size: 0.9rem;
} }
.file-viewer-content h1, .file-viewer-content h1,
.file-viewer-content h2, .file-viewer-content h2,
.file-viewer-content h3 { .file-viewer-content h3 {
margin: 1rem 0 0.5rem; margin: 0.8rem 0 0.4rem;
color: var(--accent); color: var(--accent);
} }
.file-viewer-content pre { .file-viewer-content pre {
background: var(--code-bg); background: #161b22;
border-radius: 6px; border: 1px solid var(--border);
padding: 1rem; border-radius: 4px;
padding: 0.8rem;
overflow-x: auto; overflow-x: auto;
} }
@ -441,18 +520,22 @@ a:hover {
font-family: 'JetBrains Mono', 'Fira Code', monospace; font-family: 'JetBrains Mono', 'Fira Code', monospace;
} }
.file-viewer-content p {
margin: 0.4rem 0;
}
/* Scrollbar */ /* Scrollbar */
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 8px; width: 6px;
} }
::-webkit-scrollbar-track { ::-webkit-scrollbar-track {
background: var(--bg-primary); background: #0d1117;
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: var(--border); background: var(--border);
border-radius: 4px; border-radius: 3px;
} }
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {

29
ws.go
View File

@ -1,7 +1,6 @@
package main package main
import ( import (
"bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log" "log"
@ -11,7 +10,6 @@ import (
"time" "time"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
"github.com/yuin/goldmark"
) )
var upgrader = websocket.Upgrader{ var upgrader = websocket.Upgrader{
@ -228,8 +226,24 @@ func (wh *WSHandler) listenEvents(sess *ChatSession) {
Content: fragment, Content: fragment,
Timestamp: time.Now(), Timestamp: time.Now(),
}) })
if event.Result != nil && event.Result.CostUSD > 0 { if event.Result != nil {
costMsg := FragmentSystemMessage(fmt.Sprintf("Gotovo (%.4f USD)", event.Result.CostUSD)) 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{ sess.AddMessage(ChatMessage{
Role: "system", Role: "system",
Content: costMsg, Content: costMsg,
@ -237,6 +251,7 @@ func (wh *WSHandler) listenEvents(sess *ChatSession) {
}) })
} }
} }
}
case err, ok := <-sess.Process.Errors: case err, ok := <-sess.Process.Errors:
if !ok { if !ok {
@ -274,9 +289,5 @@ func (wh *WSHandler) handleStreamEvent(sess *ChatSession, se *StreamEvent, curre
} }
func renderMarkdown(text string) string { func renderMarkdown(text string) string {
var buf bytes.Buffer return RenderMD(text)
if err := goldmark.Convert([]byte(text), &buf); err != nil {
return text
}
return buf.String()
} }