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",
|
"--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...)
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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
17
ws.go
@ -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()
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user