Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 65 additions & 2 deletions cli/src/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,14 @@ import { useShallow } from 'zustand/react/shallow'

import { getAdsEnabled } from './commands/ads'
import { routeUserPrompt, addBashMessageToHistory } from './commands/router'
import { MissionTodosTracker } from './components/mission-todos-tracker'
import { SingleAdBanner } from './components/ad-banner'
import {
buildMissionContinuation,
loadMission,
refreshMissionCompletion,
} from './missions/mission-store'
import { getMissionAutopilotAction } from './missions/mission-autopilot'
import { ChatInputBar } from './components/chat-input-bar'
import { ChatHeader } from './components/chat-header'
import { FreebuffActiveSessionSummary } from './components/freebuff-active-session-summary'
Expand Down Expand Up @@ -52,7 +59,7 @@ import { usePublishMutation } from './hooks/use-publish-mutation'
import { useSuggestionEngine } from './hooks/use-suggestion-engine'
import { useUsageMonitor } from './hooks/use-usage-monitor'
import { WEBSITE_URL } from './login/constants'
import { getProjectRoot } from './project-files'
import { getMissionScopeId, getProjectRoot } from './project-files'
import { useChatHistoryStore } from './state/chat-history-store'
import { useChatStore } from './state/chat-store'
import { useQueuePanelStore } from './state/queue-panel-store'
Expand Down Expand Up @@ -570,6 +577,13 @@ export const Chat = ({
},
)

const missionAutopilotPendingRef = useRef(false)
let missionAutopilotActive = false
try {
missionAutopilotActive =
loadMission(getProjectRoot(), getMissionScopeId())?.status === 'active'
} catch {}

// Retire onboarding suggested prompts once the user submits anything
// (typed or clicked), persisting so they don't return on future launches.
useEffect(() => {
Expand Down Expand Up @@ -1486,6 +1500,52 @@ export const Chat = ({
IS_FREEBUFF && freebuffSession?.status === 'active'
const isFreebuffSessionOver =
IS_FREEBUFF && freebuffSession?.status === 'ended'

// A mission is a persistent worker, not a single assistant turn. Whenever
// its chat becomes idle, re-check the authoritative plan and start the next
// turn. The per-chat mission file prevents parallel terminals from sharing
// or completing each other's work.
useEffect(() => {
let root: string
let scopeId: string
try {
root = getProjectRoot()
scopeId = getMissionScopeId()
} catch {
return
}
const mission = refreshMissionCompletion(root, scopeId)
const action = getMissionAutopilotAction({
active: mission?.status === 'active',
idle:
!isStreaming &&
!isWaitingForResponse &&
!isChainInProgressRef.current &&
!askUserState &&
!reviewMode,
sessionOver: isFreebuffSessionOver,
})
if (action !== 'continue' || !mission || missionAutopilotPendingRef.current) return

missionAutopilotPendingRef.current = true
const timer = setTimeout(() => {
const current = refreshMissionCompletion(root, scopeId)
if (!current || current.status !== 'active') {
missionAutopilotPendingRef.current = false
return
}
onSubmitPrompt(buildMissionContinuation(root, current, scopeId), agentMode)
.catch((error) => logger.error({ error }, '[mission-autopilot] Failed to continue mission'))
.finally(() => {
missionAutopilotPendingRef.current = false
})
}, 1500)
return () => {
clearTimeout(timer)
missionAutopilotPendingRef.current = false
}
}, [messages.length, isStreaming, isWaitingForResponse, isFreebuffSessionOver, askUserState, reviewMode, agentMode, onSubmitPrompt])

const shouldShowStatusLine =
!feedbackMode &&
(hasStatusIndicatorContent ||
Expand Down Expand Up @@ -1628,6 +1688,8 @@ export const Chat = ({
/>
)}

<MissionTodosTracker messages={messages} />

{reviewMode ? (
// Review and ask_user take precedence over the session-ended banner:
// during the grace window the agent may still be asking to run tools
Expand All @@ -1649,9 +1711,10 @@ export const Chat = ({
width={separatorWidth}
maxVisibleRows={isCompactHeight ? 4 : 8}
/>
) : isFreebuffSessionOver && !askUserState ? (
) : isFreebuffSessionOver && !askUserState && !isStreaming && !isWaitingForResponse ? (
<SessionEndedBanner
isStreaming={isStreaming || isWaitingForResponse}
autoRestart={missionAutopilotActive}
/>
) : (
<>
Expand Down
74 changes: 74 additions & 0 deletions cli/src/commands/mission.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { getMissionScopeId, getProjectRoot } from '../project-files'
import {
buildMissionPrompt,
cancelMission,
completeMission,
createMission,
formatMissionStatus,
loadMission,
} from '../missions/mission-store'

function root(): string {
return getProjectRoot() || process.cwd()
}

function scope(): string | undefined {
try {
return getMissionScopeId()
} catch {
return undefined
}
}

export type MissionCommandResult =
| { kind: 'message'; message: string }
| { kind: 'start'; prompt: string }

export function runMissionCommand(args: string): MissionCommandResult {
const trimmed = args.trim()
let [verb, ...rest] = trimmed ? trimmed.split(/\s+/) : ['status']

if (verb.endsWith(',')) {
verb = verb.slice(0, -1)
}

const value = rest.join(' ').trim()

if (verb === 'status') {
return { kind: 'message', message: formatMissionStatus(loadMission(root(), scope())) }
}
if (verb === 'start') {
if (!value) {
return { kind: 'message', message: 'Usage: /mission start <objective>' }
}
const mission = createMission(root(), value, scope())
return { kind: 'start', prompt: buildMissionPrompt(root(), mission, scope()) }
}
if (verb === 'complete') {
const evidence = value ? value.split('|').map((item) => item.trim()) : []
try {
const mission = completeMission(root(), evidence, scope())
return { kind: 'message', message: formatMissionStatus(mission) }
} catch (error) {
return {
kind: 'message',
message: error instanceof Error ? error.message : String(error),
}
}
}
if (verb === 'cancel') {
try {
const mission = cancelMission(root(), scope())
return { kind: 'message', message: formatMissionStatus(mission) }
} catch (error) {
return {
kind: 'message',
message: error instanceof Error ? error.message : String(error),
}
}
}
return {
kind: 'message',
message: 'Uso: /mission [status|start <objetivo>|complete [evidência]|cancel]',
}
}
12 changes: 11 additions & 1 deletion cli/src/commands/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ import {
} from './router-utils'
import { buildInterviewPrompt, buildPlanPrompt, buildReviewPrompt } from './prompt-builders'
import { getProjectRoot } from '../project-files'
import {
buildMissionContinuation,
loadMission,
} from '../missions/mission-store'
import { useChatStore } from '../state/chat-store'
import { useFreebuffSessionStore } from '../state/freebuff-session-store'
import { trackEvent } from '../utils/analytics'
Expand Down Expand Up @@ -457,7 +461,13 @@ export async function routeUserPrompt(
return
}

sendMessage({ content: trimmed, agentMode })
const projectRoot = getProjectRoot() || process.cwd()
const mission = loadMission(projectRoot)
const content =
mission?.status === 'active'
? trimmed + buildMissionContinuation(projectRoot, mission)
: trimmed
sendMessage({ content, agentMode })

setTimeout(() => {
scrollToLatest()
Expand Down
118 changes: 118 additions & 0 deletions cli/src/components/mission-todos-tracker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import React, { useMemo, useState } from 'react'
import fs from 'node:fs'
import { TextAttributes } from '@opentui/core'
import { useTheme } from '../hooks/use-theme'
import { getMissionScopeId, getProjectRoot } from '../project-files'
import { getMissionPath } from '../missions/mission-store'
import { ClickableTitleBox } from './clickable-title-box'
import { BORDER_CHARS } from '../utils/ui-constants'

import type { ChatMessage, ToolContentBlock } from '../types/chat'

interface MissionTodosTrackerProps {
messages: ChatMessage[]
}

export const MissionTodosTracker: React.FC<MissionTodosTrackerProps> = ({ messages }) => {
const theme = useTheme()
const projectRoot = getProjectRoot() ?? process.cwd()
const [isExpanded, setIsExpanded] = useState(false)

const missionJsonPath = useMemo(() => {
try {
return getMissionPath(projectRoot, getMissionScopeId())
} catch {
return getMissionPath(projectRoot)
}
}, [projectRoot, messages])

const missionText = useMemo(() => {
try {
if (fs.existsSync(missionJsonPath)) {
const data = JSON.parse(fs.readFileSync(missionJsonPath, 'utf8'))
if (data.status === 'active' || data.status === 'completed' || data.status === 'blocked') {
return { objective: data.objective, status: data.status }
}
}
} catch {}
return null
}, [missionJsonPath, messages])

const todos = useMemo(() => {
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i]
if (msg.blocks) {
for (const block of msg.blocks) {
if (block.type === 'tool' && (block as ToolContentBlock).toolName === 'write_todos') {
try {
const input = (block as ToolContentBlock).input
if (input && Array.isArray(input.todos)) {
return input.todos as Array<{task: string, completed: boolean}>
}
} catch {}
}
}
}
}
return null
}, [messages])

if (!missionText && !todos) return null

// Clean objective text for single line, heavily truncated to avoid Opentui dropping the title on small terminals
const shortObjective = missionText?.objective
? missionText.objective.replace(/[\r\n]+/g, ' ').substring(0, 30) + (missionText.objective.length > 30 ? '...' : '')
: 'Sem meta definida'

const completedCount = todos ? todos.filter(t => t.completed).length : 0
const totalCount = todos ? todos.length : 0

const title = ` META: ${shortObjective} | ETAPAS (${completedCount}/${totalCount}) [${isExpanded ? '▲' : '▼'}] `

let visibleTodos = todos || []

if (!isExpanded && todos && todos.length > 0) {
const firstUncompletedIndex = todos.findIndex(t => !t.completed)
const targetIndex = firstUncompletedIndex === -1 ? todos.length - 1 : firstUncompletedIndex
visibleTodos = [todos[targetIndex]]
// Attach index so we know which one it is
visibleTodos[0] = { ...visibleTodos[0], originalIndex: targetIndex } as any
} else if (todos) {
visibleTodos = todos.map((t, idx) => ({ ...t, originalIndex: idx })) as any
}

return (
<ClickableTitleBox
title={title}
onTitleClick={() => setIsExpanded(!isExpanded)}
style={{
flexDirection: 'column',
width: '100%',
marginBottom: 1,
paddingLeft: 1,
paddingRight: 1,
borderStyle: 'single',
customBorderChars: BORDER_CHARS,
borderColor: theme.success,
}}
>
{visibleTodos.map((todo: any) => (
<box key={todo.originalIndex} style={{ flexDirection: 'row', width: '100%' }}>
<text style={{ wrapMode: 'truncate' }}>
{todo.completed ? (
<>
<span fg={theme.success}>[x] </span>
<span fg={theme.muted} attributes={TextAttributes.STRIKETHROUGH}>{todo.task}</span>
</>
) : (
<>
<span fg={theme.primary}>[ ] </span>
<span fg={theme.foreground}>{todo.task}</span>
</>
)}
</text>
</box>
))}
</ClickableTitleBox>
)
}
21 changes: 18 additions & 3 deletions cli/src/components/session-ended-banner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
import { getRateLimitsByModel } from '@codebuff/common/types/freebuff-session'
import { TextAttributes } from '@opentui/core'
import { useKeyboard } from '@opentui/react'
import React, { useCallback, useState } from 'react'
import React, { useCallback, useState, useEffect } from 'react'

import { Button } from './button'
import {
Expand All @@ -28,6 +28,8 @@ interface SessionEndedBannerProps {
* grace window. Swaps the Enter-to-rejoin affordance for a "let it
* finish" hint so the user doesn't abort their in-flight work. */
isStreaming: boolean
onSessionRenewed?: () => void
autoRestart?: boolean
}

/**
Expand All @@ -37,6 +39,8 @@ interface SessionEndedBannerProps {
*/
export const SessionEndedBanner: React.FC<SessionEndedBannerProps> = ({
isStreaming,
onSessionRenewed,
autoRestart,
}) => {
const theme = useTheme()
const [pendingAction, setPendingAction] = useState<
Expand Down Expand Up @@ -112,8 +116,19 @@ export const SessionEndedBanner: React.FC<SessionEndedBannerProps> = ({
}
// Re-POST with the currently selected model and keep the chat/run state
// intact so the next prompt continues the same conversation.
refreshFreebuffSession().catch(() => setPendingAction(null))
}, [canRestart, continueOnFallback])
refreshFreebuffSession()
.then(() => {
onSessionRenewed?.()
})
.catch(() => setPendingAction(null))
}, [canRestart, continueOnFallback, onSessionRenewed])

useEffect(() => {
if (autoRestart && canRestart) {
const timer = setTimeout(startSameChatSession, 1500)
return () => clearTimeout(timer)
}
}, [autoRestart, canRestart, startSameChatSession])

useKeyboard(
useCallback(
Expand Down
Loading
Loading