Skip to content
11 changes: 10 additions & 1 deletion frontend/src/components/GitContext.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React, { useEffect, useState, useRef } from 'react';
import { gitContextStyles } from '@/utils/theme-styles';
import { CaretUpDown, FolderOpen, GitBranch, Check } from '@phosphor-icons/react';
import { CaretUpDown, FolderOpen, GitBranch, Check, Plus } from '@phosphor-icons/react';
import { ipc } from '@/ipc';
import { useStore } from '@/store';
import { theme } from '@/theme';
Expand Down Expand Up @@ -132,6 +132,15 @@ export default function GitContext({ onPickWorkspace, refreshTick = 0 }: GitCont
</div>
)}

<button
className="btn btn-ghost"
style={{ ...gitContextStyles.folderBtn, padding: '0 6px' }}
onClick={onPickWorkspace}
title="Add folder to workspace"
>
<Plus size={14} />
</button>

{gitInfo && gitInfo.branch !== 'no git' && (
<span style={gitContextStyles.branch}>
<GitBranch size={12} />
Expand Down
11 changes: 10 additions & 1 deletion frontend/src/ipc/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { listen, type UnlistenFn } from '@tauri-apps/api/event';
import { open } from '@tauri-apps/plugin-dialog';
import type {
AskUser, BgTaskExited, BgTaskInfo, ChatMessage, CompactResult, ContextWindow, DirEntry,
EditReviewRequest, FileContent, FileHit, GitContext, McpServerStatus, McpToolInfo,
EditReviewRequest, FileContent, FileHit, GitContext, LoopStatus, LoopStatusEvent, McpServerStatus, McpToolInfo,
ModelOption, ModelRole, NodeCode, NodeSummarized, Opener,
ProviderInfo, ProviderInput, ProviderStatus, PtyExit, PtyOutput,
SessionInfo, SessionModels, SessionTitle,
Expand Down Expand Up @@ -35,6 +35,15 @@ export const ipc = {
onMcpOauthUrl: (cb: (p: { server_name: string; auth_url: string }) => void) =>
on<{ server_name: string; auth_url: string }>('mcp_oauth_url', cb),
stopChatStream: (sessionId?: string) => invoke<void>('stop_chat_stream', { sessionId }),

// Self-pacing background loop for a session (/loop): after each turn the
// model calls the `schedule_wakeup` tool to keep it going, or it stops on
// its own. `prompt` overrides the default "continue from history" kickoff.
startLoop: (sessionId?: string, prompt?: string, forever?: boolean) =>
invoke<void>('start_loop', { sessionId, prompt, forever }),
stopLoop: (sessionId?: string) => invoke<void>('stop_loop', { sessionId }),
getLoopStatus: (sessionId?: string) => invoke<LoopStatus>('get_loop_status', { sessionId }),
onLoopStatus: (cb: (p: LoopStatusEvent) => void) => on<LoopStatusEvent>('loop_status', cb),
answerQuestion: (answer: string, sessionId?: string) => invoke<void>('answer_question', { answer, sessionId }),
getHistory: (sessionId?: string) => invoke<ChatMessage[]>('get_history', { sessionId }),
clearHistory: (sessionId?: string) => invoke<void>('clear_history', { sessionId }),
Expand Down
16 changes: 16 additions & 0 deletions frontend/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,22 @@ export interface StreamUsage {
cost: number;
}

/** Result of `get_loop_status`, and the payload of the `loop_status` event
* (which also carries `session_id`). */
export interface LoopStatus {
active: boolean;
/** "running" | "waiting" | "stopped" */
status: string;
pending_delay_secs?: number | null;
pending_reason?: string | null;
/** Started with /loop --forever: keeps going even if the model doesn't ask to continue. */
forever: boolean;
}

export interface LoopStatusEvent extends LoopStatus {
session_id: string;
}

export interface AskUser {
session_id: string;
args: string;
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/utils/chatHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export interface CommandContext {
workspace: () => void | Promise<void>;
scan: () => void | Promise<void>;
summarize: (concurrency?: number) => void | Promise<void>;
loop: (prompt?: string, forever?: boolean) => void | Promise<void>;
}

export interface SlashCommand {
Expand All @@ -67,6 +68,7 @@ export const COMMANDS: SlashCommand[] = [
{ cmd: '/workspace', desc: 'Switch workspace folder', run: (ctx) => ctx.workspace() },
{ cmd: '/scan', desc: 'Rescan workspace into the graph', run: (ctx) => ctx.scan() },
{ cmd: '/summarize', desc: 'Summarize stale & unsummarized nodes (e.g. /summarize 8)', run: (ctx) => ctx.summarize() },
{ cmd: '/loop', desc: 'Keep the session running, self-paced (/loop watch the build, /loop --forever monitor logs, /loop stop)', run: (ctx) => ctx.loop() },
];

export const COL_W = 760;
1 change: 1 addition & 0 deletions frontend/src/utils/theme-styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1035,6 +1035,7 @@ export const chatStyles: Record<string, CSSProperties> = {
activityText: { color: theme.dim, fontSize: 13, fontStyle: 'italic' },
composerWrap: { position: 'relative', display: 'flex', justifyContent: 'center', padding: '0 0 28px', background: theme.bg },
composerFade: { position: 'absolute', left: 0, right: 0, top: -32, height: 32, pointerEvents: 'none', background: `linear-gradient(to bottom, transparent, ${theme.bg})` },
loopPill: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, padding: '5px 10px', borderRadius: 'var(--radius-md)', background: theme.card, border: `1px solid ${theme.border}`, color: theme.textSoft, fontSize: 12 },
card: { background: theme.card, borderRadius: 16, padding: '14px 16px' },
textarea: { width: '100%', background: 'transparent', border: 'none', outline: 'none', color: theme.text, fontSize: 14, resize: 'none', fontFamily: 'inherit', lineHeight: 1.5, maxHeight: 200 },
attachRow: { display: 'flex', marginBottom: 8 },
Expand Down
58 changes: 55 additions & 3 deletions frontend/src/views/Chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { useWorkspace } from '@/hooks/useWorkspace';
import { COMMANDS, type Attachment, type CommandContext, type ChatMessageView, type RenderedItem, type SlashCommand } from '@/utils/chatHelpers';
import { MIN_SCAN_MS } from '@/utils/treemapHelpers';
import { chatStyles as styles } from '@/utils/theme-styles';
import type { EditReviewRequest, FileHit, SkillSummary, ToolConfirmRequest, Usage } from '@/types';
import type { EditReviewRequest, FileHit, LoopStatus, SkillSummary, ToolConfirmRequest, Usage } from '@/types';
import type { StreamPart, StreamState } from '@/components/StreamStatus';
import type { Question } from '@/components/QuestionCard';

Expand Down Expand Up @@ -94,6 +94,8 @@ export default function Chat() {
const streamsRef = useRef<Record<string, StreamSession>>({});
const [streamsBySession, setStreamsBySession] = useState<Record<string, StreamSession>>({});
const streaming = streamsBySession[viewingSession] ?? null;
const [loopStatusBySession, setLoopStatusBySession] = useState<Record<string, LoopStatus>>({});
const loopStatus = loopStatusBySession[viewingSession];

// ── Fetch history on session change ────────────────────────────────────────
useEffect(() => {
Expand Down Expand Up @@ -158,11 +160,24 @@ export default function Chat() {
ipc.onStreamUsage(({ session_id, ...u }) => {
if (streamsRef.current[session_id]) streamsRef.current[session_id].usage = u;
});
ipc.onLoopStatus(({ session_id, ...status }) => {
setLoopStatusBySession((prev) => ({ ...prev, [session_id]: status }));
});
}, []);

function pushTo(sessionId: string, key: 'content' | 'thinking' | 'tools', chunk: string) {
const cur = streamsRef.current[sessionId];
if (!cur) return;
let cur = streamsRef.current[sessionId];
if (!cur) {
// No buffer yet means this turn wasn't kicked off by send() — e.g. a
// /loop tick fired from the background driver thread with no frontend
// call in between. Create one now so the stream still renders live,
// instead of silently dropping every chunk.
cur = { thinking: '', parts: [], startedAt: Date.now() };
streamsRef.current[sessionId] = cur;
setStreamsBySession((prev) => ({ ...prev, [sessionId]: cur! }));
useStore.getState().setLoading(true);
useStore.getState().setStreamingSession(sessionId);
}
// Once the user hits stop, drop any chunks still arriving while the backend
// unwinds, so content visibly stops immediately.
if (cur.canceled) return;
Expand Down Expand Up @@ -324,6 +339,23 @@ export default function Chat() {
return;
}

// /loop [prompt] and /loop stop shortcuts
if (/^\/loop\s+stop$/i.test(content)) {
setInput('');
if (taRef.current) taRef.current.style.height = 'auto';
await ipc.stopLoop(activeSession).catch(console.error);
return;
}
const loopMatch = content.match(/^\/loop(?:\s+(--forever|-f))?(?:\s+([\s\S]+))?$/i);
if (loopMatch) {
setInput('');
if (taRef.current) taRef.current.style.height = 'auto';
addMessage(activeSession, { role: 'user', content });
setGitRefreshTick((t) => t + 1);
await ipc.startLoop(activeSession, loopMatch[2]?.trim() || undefined, !!loopMatch[1]).catch(console.error);
return;
}

// #skill mentions auto-enable the skill before the stream starts, so the
// backend injects its body into this message's system prompt.
// Only #tokens at the start of the text or after whitespace count — a URL
Expand Down Expand Up @@ -521,6 +553,10 @@ export default function Chat() {
},
workspace: () => pickWorkspace(viewingSession),
summarize: async (concurrency?: number) => { await ipc.summarizeAll(concurrency).catch(console.error); },
loop: async (prompt?: string, forever?: boolean) => {
addMessage(viewingSession, { role: 'user', content: prompt ? `/loop ${prompt}` : '/loop' });
await ipc.startLoop(viewingSession, prompt, forever).catch(console.error);
},
scan: async () => {
const t0 = performance.now();
setScanning(true);
Expand Down Expand Up @@ -631,6 +667,22 @@ export default function Chat() {
onCancel={() => ipc.stopSummarize().catch(console.error)}
/>
)}
{loopStatus?.active && (
<div style={styles.loopPill}>
<span>
{loopStatus.forever ? '∞ ' : ''}
{loopStatus.status === 'waiting'
? `Loop: next in ${loopStatus.pending_delay_secs ?? '?'}s${loopStatus.pending_reason ? ` — ${loopStatus.pending_reason}` : ''}`
: 'Loop: running…'}
</span>
<button
className="btn btn-ghost"
onClick={() => ipc.stopLoop(viewingSession).catch(console.error)}
>
Stop
</button>
</div>
)}
<GitContext onPickWorkspace={() => pickWorkspace(viewingSession)} refreshTick={gitRefreshTick} />
<Composer
input={input}
Expand Down
16 changes: 15 additions & 1 deletion src/backend/llm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,13 @@ pub enum StreamEvent {
/// Raw HTTP response payload received from the provider (the concatenated
/// SSE/NDJSON stream), emitted once when the turn finishes.
ResponseRaw(String),
/// The provider's `finish_reason` for this turn (`"length"`, `"stop"`,
/// `"tool_calls"`, …), when the wire format reports one. `"length"`
/// specifically means the completion was cut off by the token budget —
/// distinct from the model simply choosing to say nothing, so callers can
/// react by growing the budget instead of just nudging and repeating the
/// same request.
FinishReason(String),
Done,
}

Expand Down Expand Up @@ -270,12 +277,18 @@ pub trait Provider: Send + Sync {
/// Begin a streamed turn with full history. `tools_json` is the JSON array
/// of tool definitions to advertise this turn (already filtered by the
/// caller for the active mode); an empty string or `"[]"` omits tools
/// entirely so the model can only reply with text.
/// entirely so the model can only reply with text. `max_tokens_override`,
/// when set, replaces the provider's normal computed completion budget —
/// used to escalate the budget on retry after a turn was cut off
/// (`finish_reason == "length"`) with no content, since repeating the
/// identical request tends to repeat the same runaway reasoning and
/// truncate again.
fn start_stream(
&self,
model: &str,
history: &[Message],
tools_json: &str,
max_tokens_override: Option<usize>,
) -> BackendResult<Box<dyn ChatStream>>;

/// Serializes one or more tool calls made in the SAME assistant turn
Expand Down Expand Up @@ -438,6 +451,7 @@ mod tests {
_: &str,
_: &[Message],
_: &str,
_: Option<usize>,
) -> BackendResult<Box<dyn ChatStream>> {
unimplemented!()
}
Expand Down
141 changes: 141 additions & 0 deletions src/backend/loop_registry.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
//! Process-global registry of active `/loop` sessions, keyed by session id —
//! same shape as `tools::bg`'s registry, since both need state reachable from
//! a tool call (which only carries a `session_id`, no `AppHandle`) and from a
//! background driver thread.
//!
//! A loop is self-paced: after each turn the model is expected to call the
//! `schedule_wakeup` tool to say when it wants to run again. The driver
//! thread (`backend::loop_runner`) reads that request after the turn ends. If
//! the model doesn't call it, the loop ends on its own — same "no reschedule,
//! no continuation" rule as Claude Code's dynamic `/loop`.

use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, OnceLock};

struct LoopEntry {
cancel: Arc<AtomicBool>,
/// Bumped on every `start`; lets a stale sleeping thread from a
/// superseded loop detect it's no longer the active one and no-op instead
/// of firing a second, duplicate loop for the same session.
generation: u64,
pending_wakeup: Option<(u64, String)>,
/// Human-readable status for the UI: "running" | "waiting" | "stopped".
status: &'static str,
/// `/loop --forever`: keep going even if the model doesn't call
/// `schedule_wakeup` — only an explicit `stop_loop` call or manual stop
/// ends it. See `loop_runner::run`.
forever: bool,
}

fn registry() -> &'static Mutex<HashMap<String, LoopEntry>> {
static REGISTRY: OnceLock<Mutex<HashMap<String, LoopEntry>>> = OnceLock::new();
REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
}

/// Snapshot for the frontend / `get_loop_status`.
#[derive(Clone, serde::Serialize)]
pub struct LoopStatus {
pub active: bool,
pub status: String,
pub pending_delay_secs: Option<u64>,
pub pending_reason: Option<String>,
pub forever: bool,
}

/// Register a new loop for `session_id`, replacing (and implicitly
/// cancelling) any previous one. Returns the cancel flag and generation the
/// driver thread must use.
pub fn start(session_id: &str, forever: bool) -> (Arc<AtomicBool>, u64) {
let mut reg = registry().lock().unwrap();
let generation = reg.get(session_id).map(|e| e.generation + 1).unwrap_or(0);
// Cancel whatever was running before so its thread unwinds on its next check.
if let Some(prev) = reg.get(session_id) {
prev.cancel.store(true, Ordering::SeqCst);
}
let cancel = Arc::new(AtomicBool::new(false));
reg.insert(
session_id.to_string(),
LoopEntry {
cancel: cancel.clone(),
generation,
pending_wakeup: None,
status: "running",
forever,
},
);
(cancel, generation)
}

/// Whether `session_id`'s loop (current generation) is a `--forever` loop.
pub fn is_forever(session_id: &str, generation: u64) -> bool {
let reg = registry().lock().unwrap();
reg.get(session_id)
.filter(|e| e.generation == generation)
.map(|e| e.forever)
.unwrap_or(false)
}

/// Called by the `schedule_wakeup` tool. No-op if there's no active loop for
/// this session (e.g. the model called it outside of a `/loop`).
pub fn set_pending_wakeup(session_id: &str, delay_secs: u64, reason: String) -> bool {
let mut reg = registry().lock().unwrap();
match reg.get_mut(session_id) {
Some(e) => {
e.pending_wakeup = Some((delay_secs, reason));
true
}
None => false,
}
}

/// Take (and clear) the pending wakeup request for the current generation of
/// `session_id`'s loop. Returns `None` both when nothing was scheduled and
/// when the entry has since moved to a newer generation (superseded).
pub fn take_pending_wakeup(session_id: &str, generation: u64) -> Option<(u64, String)> {
let mut reg = registry().lock().unwrap();
let entry = reg.get_mut(session_id)?;
if entry.generation != generation {
return None;
}
entry.pending_wakeup.take()
}

pub fn set_status(session_id: &str, generation: u64, status: &'static str) {
let mut reg = registry().lock().unwrap();
if let Some(e) = reg.get_mut(session_id) {
if e.generation == generation {
e.status = status;
}
}
}

/// Stops the loop (if any) for `session_id` — called on explicit user cancel,
/// the `stop_loop` tool, session deletion, or the driver thread exiting.
pub fn stop(session_id: &str) {
let mut reg = registry().lock().unwrap();
if let Some(e) = reg.get_mut(session_id) {
e.cancel.store(true, Ordering::SeqCst);
e.status = "stopped";
}
}

pub fn status(session_id: &str) -> LoopStatus {
let reg = registry().lock().unwrap();
match reg.get(session_id) {
Some(e) if e.status != "stopped" => LoopStatus {
active: true,
status: e.status.to_string(),
pending_delay_secs: e.pending_wakeup.as_ref().map(|(d, _)| *d),
pending_reason: e.pending_wakeup.as_ref().map(|(_, r)| r.clone()),
forever: e.forever,
},
_ => LoopStatus {
active: false,
status: "stopped".to_string(),
pending_delay_secs: None,
pending_reason: None,
forever: false,
},
}
}
Loading
Loading