Ispravka dupliranja poruka i dodat Plan/Code mod
All checks were successful
Tests / unit-tests (push) Successful in 8s
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:
parent
0ce03c27e3
commit
93dbb33198
@ -108,7 +108,6 @@ func SpawnCLI(projectDir string) (*CLIProcess, error) {
|
||||
"--input-format", "stream-json",
|
||||
"--output-format", "stream-json",
|
||||
"--verbose",
|
||||
"--include-partial-messages",
|
||||
}
|
||||
|
||||
cmd := exec.Command("claude", args...)
|
||||
|
||||
@ -8,9 +8,13 @@ import (
|
||||
)
|
||||
|
||||
// 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)
|
||||
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.
|
||||
|
||||
@ -6,7 +6,7 @@ import (
|
||||
)
|
||||
|
||||
func TestFragmentUserMessage(t *testing.T) {
|
||||
f := FragmentUserMessage("Hello <world>")
|
||||
f := FragmentUserMessage("Hello <world>", false)
|
||||
if !strings.Contains(f, "message-user") {
|
||||
t.Error("missing message-user class")
|
||||
}
|
||||
@ -16,6 +16,15 @@ func TestFragmentUserMessage(t *testing.T) {
|
||||
if !strings.Contains(f, `hx-swap-oob="beforeend"`) {
|
||||
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) {
|
||||
|
||||
@ -107,6 +107,15 @@ a:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--bg-tertiary);
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.btn-full {
|
||||
width: 100%;
|
||||
}
|
||||
@ -121,6 +130,22 @@ a:hover {
|
||||
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-container {
|
||||
max-width: 1000px;
|
||||
@ -352,6 +377,59 @@ a:hover {
|
||||
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 */
|
||||
.chat-input-area {
|
||||
padding: 0.5rem 1rem;
|
||||
|
||||
@ -42,7 +42,15 @@
|
||||
<div id="typing-indicator"></div>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
<button type="submit" class="btn">Pošalji</button>
|
||||
</form>
|
||||
@ -61,6 +69,27 @@
|
||||
</div>
|
||||
|
||||
<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
|
||||
const textarea = document.getElementById('message-input');
|
||||
if (textarea) {
|
||||
@ -69,15 +98,29 @@
|
||||
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) {
|
||||
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();
|
||||
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
|
||||
const chatMessages = document.getElementById('chat-messages');
|
||||
const observer = new MutationObserver(function() {
|
||||
@ -100,13 +143,6 @@
|
||||
function closeFileViewer() {
|
||||
document.getElementById('file-viewer').classList.add('hidden');
|
||||
}
|
||||
|
||||
// Close file viewer with Escape
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
closeFileViewer();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
17
ws.go
17
ws.go
@ -20,6 +20,7 @@ var upgrader = websocket.Upgrader{
|
||||
|
||||
type wsMessage struct {
|
||||
Message string `json:"message"`
|
||||
Mode string `json:"mode"` // "code" or "plan"
|
||||
}
|
||||
|
||||
// WSHandler handles WebSocket connections for chat.
|
||||
@ -134,8 +135,10 @@ func (wh *WSHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
continue
|
||||
}
|
||||
|
||||
isPlan := msg.Mode == "plan"
|
||||
|
||||
// Add user message to buffer (broadcasts to all subscribers including us)
|
||||
userFragment := FragmentUserMessage(text)
|
||||
userFragment := FragmentUserMessage(text, isPlan)
|
||||
sess.AddMessage(ChatMessage{
|
||||
Role: "user",
|
||||
Content: userFragment,
|
||||
@ -145,8 +148,14 @@ func (wh *WSHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// Clear input and show typing
|
||||
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
|
||||
if err := sess.Process.Send(text); err != nil {
|
||||
if err := sess.Process.Send(cliText); err != nil {
|
||||
log.Printf("Send to CLI error: %v", err)
|
||||
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) {
|
||||
switch se.Type {
|
||||
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++
|
||||
*currentMsgID = fmt.Sprintf("msg-%d-%d", time.Now().UnixMilli(), *msgCounter)
|
||||
currentText.Reset()
|
||||
|
||||
Loading…
Reference in New Issue
Block a user