Terminal UI stil, markdown tabele i prikaz troškova
All checks were successful
Tests / unit-tests (push) Successful in 8s
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:
parent
9d0e507689
commit
0ce03c27e3
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
10
files.go
10
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
|
||||
}
|
||||
|
||||
29
markdown.go
Normal file
29
markdown.go
Normal 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()
|
||||
}
|
||||
261
static/style.css
261
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 {
|
||||
|
||||
39
ws.go
39
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)
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user