Ispravka dupliranja poruka i dodat Plan/Code mod
All checks were successful
Tests / unit-tests (push) Successful in 8s

- Uklonjen --include-partial-messages (izazivao duple assistant evente)
- content_block_start preskače tool_use blokove (prazni divovi)
- Shift+Tab prebacuje između Code i Plan moda
- Plan mod šalje instrukciju da Claude samo planira bez izmena
- CSS za mode bar i plan poruke (plava boja, ⊞ prefix)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
djuka 2026-02-18 05:49:46 +00:00
parent 0ce03c27e3
commit 93dbb33198
6 changed files with 154 additions and 15 deletions

View File

@ -108,7 +108,6 @@ func SpawnCLI(projectDir string) (*CLIProcess, error) {
"--input-format", "stream-json", "--input-format", "stream-json",
"--output-format", "stream-json", "--output-format", "stream-json",
"--verbose", "--verbose",
"--include-partial-messages",
} }
cmd := exec.Command("claude", args...) cmd := exec.Command("claude", args...)

View File

@ -8,9 +8,13 @@ import (
) )
// FragmentUserMessage returns an HTML fragment for a user message. // FragmentUserMessage returns an HTML fragment for a user message.
func FragmentUserMessage(text string) string { func FragmentUserMessage(text string, isPlan bool) string {
escaped := html.EscapeString(text) escaped := html.EscapeString(text)
return fmt.Sprintf(`<div id="chat-messages" hx-swap-oob="beforeend"><div class="message message-user">%s</div></div>`, escaped) modeClass := "message-user"
if isPlan {
modeClass = "message-user message-user-plan"
}
return fmt.Sprintf(`<div id="chat-messages" hx-swap-oob="beforeend"><div class="message %s">%s</div></div>`, modeClass, escaped)
} }
// FragmentAssistantStart returns the opening tag for an assistant message with streaming. // FragmentAssistantStart returns the opening tag for an assistant message with streaming.

View File

@ -6,7 +6,7 @@ import (
) )
func TestFragmentUserMessage(t *testing.T) { func TestFragmentUserMessage(t *testing.T) {
f := FragmentUserMessage("Hello <world>") f := FragmentUserMessage("Hello <world>", false)
if !strings.Contains(f, "message-user") { if !strings.Contains(f, "message-user") {
t.Error("missing message-user class") t.Error("missing message-user class")
} }
@ -16,6 +16,15 @@ func TestFragmentUserMessage(t *testing.T) {
if !strings.Contains(f, `hx-swap-oob="beforeend"`) { if !strings.Contains(f, `hx-swap-oob="beforeend"`) {
t.Error("missing OOB swap") t.Error("missing OOB swap")
} }
if strings.Contains(f, "message-user-plan") {
t.Error("should not have plan class in code mode")
}
// Plan mode
fp := FragmentUserMessage("analyze this", true)
if !strings.Contains(fp, "message-user-plan") {
t.Error("missing plan class in plan mode")
}
} }
func TestFragmentAssistantStart(t *testing.T) { func TestFragmentAssistantStart(t *testing.T) {

View File

@ -107,6 +107,15 @@ a:hover {
background: var(--accent-hover); background: var(--accent-hover);
} }
.btn-secondary {
background: var(--bg-tertiary);
margin-right: 0.5rem;
}
.btn-secondary:hover {
background: var(--border);
}
.btn-full { .btn-full {
width: 100%; width: 100%;
} }
@ -121,6 +130,22 @@ a:hover {
text-align: center; text-align: center;
} }
.success-msg {
background: rgba(76, 175, 80, 0.15);
color: var(--success);
padding: 0.6rem 1rem;
border-radius: 8px;
margin-bottom: 1rem;
font-size: 0.9rem;
text-align: center;
}
.form-footer {
margin-top: 1rem;
text-align: center;
font-size: 0.85rem;
}
/* Projects grid */ /* Projects grid */
.projects-container { .projects-container {
max-width: 1000px; max-width: 1000px;
@ -352,6 +377,59 @@ a:hover {
80%, 100% { opacity: 1; } 80%, 100% { opacity: 1; }
} }
/* Mode bar */
.mode-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.3rem 0;
margin-bottom: 0.3rem;
}
.mode-indicator {
display: flex;
gap: 0;
border: 1px solid var(--border);
border-radius: 4px;
overflow: hidden;
}
.mode-label {
padding: 0.2rem 0.6rem;
font-size: 0.75rem;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
color: var(--text-muted);
cursor: pointer;
transition: all 0.15s;
user-select: none;
}
.mode-label:hover {
color: var(--text-secondary);
background: rgba(255, 255, 255, 0.05);
}
.mode-label.mode-active {
background: var(--accent);
color: #fff;
}
.mode-hint {
font-size: 0.65rem;
color: var(--text-muted);
font-family: 'JetBrains Mono', 'Fira Code', monospace;
}
/* Plan mode user messages */
.message-user-plan {
color: #64b5f6 !important;
}
.message-user-plan::before {
content: "⊞ " !important;
color: #64b5f6 !important;
}
/* Input area — terminal prompt */ /* Input area — terminal prompt */
.chat-input-area { .chat-input-area {
padding: 0.5rem 1rem; padding: 0.5rem 1rem;

View File

@ -42,7 +42,15 @@
<div id="typing-indicator"></div> <div id="typing-indicator"></div>
<div class="chat-input-area" hx-ext="ws" ws-connect="/ws?project={{.Project}}&project_dir={{.ProjectDir}}"> <div class="chat-input-area" hx-ext="ws" ws-connect="/ws?project={{.Project}}&project_dir={{.ProjectDir}}">
<div class="mode-bar">
<span class="mode-indicator" id="mode-indicator">
<span class="mode-label mode-active" id="mode-code" onclick="setMode('code')">Code</span>
<span class="mode-label" id="mode-plan" onclick="setMode('plan')">Plan</span>
</span>
<span class="mode-hint">Shift+Tab za promenu moda</span>
</div>
<form class="chat-input-form" ws-send> <form class="chat-input-form" ws-send>
<input type="hidden" name="mode" id="mode-input" value="code">
<textarea id="message-input" name="message" class="chat-input" placeholder="Pošalji poruku..." rows="1"></textarea> <textarea id="message-input" name="message" class="chat-input" placeholder="Pošalji poruku..." rows="1"></textarea>
<button type="submit" class="btn">Pošalji</button> <button type="submit" class="btn">Pošalji</button>
</form> </form>
@ -61,6 +69,27 @@
</div> </div>
<script> <script>
// Mode switching
let currentMode = 'code';
function setMode(mode) {
currentMode = mode;
document.getElementById('mode-input').value = mode;
const codeEl = document.getElementById('mode-code');
const planEl = document.getElementById('mode-plan');
const textarea = document.getElementById('message-input');
if (mode === 'plan') {
codeEl.classList.remove('mode-active');
planEl.classList.add('mode-active');
textarea.placeholder = 'Plan mod — opiši šta treba analizirati...';
} else {
planEl.classList.remove('mode-active');
codeEl.classList.add('mode-active');
textarea.placeholder = 'Pošalji poruku...';
}
textarea.focus();
}
// Auto-resize textarea // Auto-resize textarea
const textarea = document.getElementById('message-input'); const textarea = document.getElementById('message-input');
if (textarea) { if (textarea) {
@ -69,15 +98,29 @@
this.style.height = Math.min(this.scrollHeight, 200) + 'px'; this.style.height = Math.min(this.scrollHeight, 200) + 'px';
}); });
// Submit on Enter (Shift+Enter for newline) // Submit on Enter (Shift+Enter for newline), Shift+Tab for mode switch
textarea.addEventListener('keydown', function(e) { textarea.addEventListener('keydown', function(e) {
if (e.key === 'Enter' && !e.shiftKey) { if (e.key === 'Tab' && e.shiftKey) {
e.preventDefault();
setMode(currentMode === 'code' ? 'plan' : 'code');
} else if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
this.closest('form').dispatchEvent(new Event('submit', { bubbles: true })); this.closest('form').dispatchEvent(new Event('submit', { bubbles: true }));
} }
}); });
} }
// Global Shift+Tab handler (works even when textarea not focused)
document.addEventListener('keydown', function(e) {
if (e.key === 'Tab' && e.shiftKey) {
e.preventDefault();
setMode(currentMode === 'code' ? 'plan' : 'code');
}
if (e.key === 'Escape') {
closeFileViewer();
}
});
// Auto-scroll chat // Auto-scroll chat
const chatMessages = document.getElementById('chat-messages'); const chatMessages = document.getElementById('chat-messages');
const observer = new MutationObserver(function() { const observer = new MutationObserver(function() {
@ -100,13 +143,6 @@
function closeFileViewer() { function closeFileViewer() {
document.getElementById('file-viewer').classList.add('hidden'); document.getElementById('file-viewer').classList.add('hidden');
} }
// Close file viewer with Escape
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
closeFileViewer();
}
});
</script> </script>
</body> </body>
</html> </html>

17
ws.go
View File

@ -20,6 +20,7 @@ var upgrader = websocket.Upgrader{
type wsMessage struct { type wsMessage struct {
Message string `json:"message"` Message string `json:"message"`
Mode string `json:"mode"` // "code" or "plan"
} }
// WSHandler handles WebSocket connections for chat. // WSHandler handles WebSocket connections for chat.
@ -134,8 +135,10 @@ func (wh *WSHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
continue continue
} }
isPlan := msg.Mode == "plan"
// Add user message to buffer (broadcasts to all subscribers including us) // Add user message to buffer (broadcasts to all subscribers including us)
userFragment := FragmentUserMessage(text) userFragment := FragmentUserMessage(text, isPlan)
sess.AddMessage(ChatMessage{ sess.AddMessage(ChatMessage{
Role: "user", Role: "user",
Content: userFragment, Content: userFragment,
@ -145,8 +148,14 @@ func (wh *WSHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Clear input and show typing // Clear input and show typing
send(FragmentCombine(FragmentClearInput(), FragmentTypingIndicator(true))) send(FragmentCombine(FragmentClearInput(), FragmentTypingIndicator(true)))
// In plan mode, wrap the message with planning instructions
cliText := text
if isPlan {
cliText = "[PLAN MODE] Only analyze, plan, and explain. Do NOT use tools to write, edit, or create files. Do NOT execute commands. Just provide your analysis and step-by-step plan.\n\n" + text
}
// Send to claude CLI // Send to claude CLI
if err := sess.Process.Send(text); err != nil { if err := sess.Process.Send(cliText); err != nil {
log.Printf("Send to CLI error: %v", err) log.Printf("Send to CLI error: %v", err)
send(FragmentSystemMessage("Greška pri slanju poruke")) send(FragmentSystemMessage("Greška pri slanju poruke"))
} }
@ -265,6 +274,10 @@ func (wh *WSHandler) listenEvents(sess *ChatSession) {
func (wh *WSHandler) handleStreamEvent(sess *ChatSession, se *StreamEvent, currentMsgID *string, currentText *strings.Builder, msgCounter *int) { func (wh *WSHandler) handleStreamEvent(sess *ChatSession, se *StreamEvent, currentMsgID *string, currentText *strings.Builder, msgCounter *int) {
switch se.Type { switch se.Type {
case "content_block_start": case "content_block_start":
// Only create a message div for text blocks, skip tool_use blocks
if se.ContentBlock != nil && se.ContentBlock.Type != "text" {
return
}
*msgCounter++ *msgCounter++
*currentMsgID = fmt.Sprintf("msg-%d-%d", time.Now().UnixMilli(), *msgCounter) *currentMsgID = fmt.Sprintf("msg-%d-%d", time.Now().UnixMilli(), *msgCounter)
currentText.Reset() currentText.Reset()