Skip to content

Commit bec3a63

Browse files
committed
feat: Plan Mode — Cursor-style structured plans with Execute/Edit buttons
- New lib/plan-parser.ts: extracts titled steps, descriptions, file refs from markdown - Plan detection in chat-stream.ts final handler (type: 'plan', planSteps field) - Fixed interactive condition: agentMode === 'plan' (was 'ask') - Execute Plan sends explicit instruction instead of empty sendMessage() - Edit Plan button for modifying plans before execution - Running state indicator during plan execution - Enhanced mode prompt enforces structured numbered-list format - Mobile-friendly touch targets (36px min-height, 100px min-width)
1 parent 9d3c892 commit bec3a63

8 files changed

Lines changed: 219 additions & 64 deletions

File tree

components/agent-panel.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -884,7 +884,7 @@ export function AgentPanel() {
884884
agentMode === 'ask'
885885
? '[Mode: Ask — discuss and answer questions. Do not make code changes unless explicitly asked.]\n'
886886
: agentMode === 'plan'
887-
? '[Mode: Plan — outline a step-by-step plan before making changes. Present the plan to the user for approval before executing.]\n'
887+
? '[Mode: Plan — You MUST respond with a structured plan before making any changes. Format your response as a numbered list where each step has a **bold title** followed by a description and affected files in backticks. Example:\n1. **Update auth module** — Add token refresh logic\n `lib/auth.ts`, `lib/api.ts`\n2. **Add tests** — Cover the new refresh flow\n `tests/auth.test.ts`\nAfter the user approves, execute each step sequentially. Do NOT make changes until approved.]\n'
888888
: '[Mode: Agent — make direct code changes and edits autonomously.]\n'
889889
return [modePrefix, context || '', attachCtx].filter(Boolean).join('\n\n')
890890
}, [agentMode, buildAttachmentContext, buildContext])

components/chat/message-list.tsx

Lines changed: 9 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
44
import { Icon } from '@iconify/react'
55
import { KnotLogo } from '@/components/knot-logo'
66
import { MarkdownPreview } from '@/components/markdown-preview'
7-
import { PlanView, type PlanStep } from '@/components/plan-view'
7+
import { PlanView } from '@/components/plan-view'
8+
import type { ParsedPlanStep } from '@/lib/plan-parser'
89
import { navigateToLine } from '@/lib/line-links'
910
import { copyToClipboard } from '@/lib/clipboard'
1011
import { useChatAppearance } from '@/context/chat-appearance-context'
@@ -22,26 +23,10 @@ interface MessageListProps {
2223
onDeleteMessage: (id: string) => void
2324
onRegenerate: (id: string) => void
2425
onEditAndResend: (id: string) => void
25-
onSendMessage: () => void
26+
onSendMessage: (text?: string) => void
2627
}
2728

28-
function parsePlanSteps(text: string): PlanStep[] {
29-
const steps: PlanStep[] = []
30-
const seenByNumber = new Map<string, number>()
31-
const matches = text.matchAll(/^(\d+)\.\s+\*{0,2}([^*]+?)\*{0,2}\s*$/gm)
32-
for (const m of matches) {
33-
const stepNumber = m[1]
34-
const seenCount = (seenByNumber.get(stepNumber) ?? 0) + 1
35-
seenByNumber.set(stepNumber, seenCount)
36-
steps.push({
37-
id: `step-${stepNumber}-${seenCount}`,
38-
title: m[2].trim(),
39-
description: undefined,
40-
status: 'pending',
41-
})
42-
}
43-
return steps
44-
}
29+
// Plan parsing moved to lib/plan-parser.ts
4530

4631
const SYSTEM_PROMPT_SIGNATURES = [
4732
'You are KnotCode Agent',
@@ -543,12 +528,12 @@ export function MessageList({
543528
</div>
544529
)}
545530
<MarkdownPreview content={msg.content} />
546-
{isAssistant && parsePlanSteps(msg.content).length >= 3 && (
531+
{isAssistant && msg.type === 'plan' && msg.planSteps && (
547532
<PlanView
548-
steps={parsePlanSteps(msg.content)}
549-
interactive={agentMode === 'ask'}
550-
onApprove={() => onSendMessage()}
551-
onSkip={() => onSendMessage()}
533+
steps={msg.planSteps}
534+
interactive={agentMode === 'plan'}
535+
onApprove={() => onSendMessage('Execute the plan above. Proceed step by step.')}
536+
onReject={() => onSendMessage('I want to modify this plan. Let me explain what to change.')}
552537
/>
553538
)}
554539
</div>

components/git-sidebar-addons.tsx

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -324,7 +324,7 @@ function PluginCompactState({ pluginId, pipActive }: { pluginId: string; pipActi
324324

325325
return (
326326
<div className="p-3 text-[10px] text-[var(--text-tertiary)]">
327-
Compact controls are not available for this add-on yet.
327+
Compact controls are not available for this widget yet.
328328
</div>
329329
)
330330
}
@@ -390,7 +390,7 @@ export function GitSidebarAddons() {
390390
<div className="flex items-center gap-2 px-3 py-2.5">
391391
<div className="min-w-0 flex-1">
392392
<div className="text-[10px] font-semibold uppercase tracking-[0.18em] text-[var(--text-disabled)]">
393-
Add-ons
393+
Widgets
394394
</div>
395395
<p className="mt-0.5 text-[10px] text-[var(--text-tertiary)]">
396396
Switch between media tools without leaving the git panel.
@@ -400,8 +400,8 @@ export function GitSidebarAddons() {
400400
type="button"
401401
onClick={() => setCollapsed((current) => !current)}
402402
className="inline-flex h-8 w-8 items-center justify-center rounded-xl border border-[var(--border)] bg-[var(--bg-elevated)] text-[var(--text-secondary)] transition hover:border-[var(--border-hover)] hover:text-[var(--text-primary)]"
403-
title={collapsed ? 'Expand add-ons' : 'Collapse add-ons'}
404-
aria-label={collapsed ? 'Expand add-ons' : 'Collapse add-ons'}
403+
title={collapsed ? 'Expand widgets' : 'Collapse widgets'}
404+
aria-label={collapsed ? 'Expand widgets' : 'Collapse widgets'}
405405
>
406406
<Icon
407407
icon={collapsed ? 'lucide:chevron-down' : 'lucide:chevron-up'}
@@ -567,7 +567,7 @@ export function GitSidebarAddons() {
567567
<div className="space-y-3 p-3">
568568
<div className="rounded-2xl border border-dashed border-[var(--border)] bg-[var(--bg)] p-3">
569569
<p className="text-[11px] font-medium text-[var(--text-primary)]">
570-
Add this to the git rail
570+
Add this widget to the git rail
571571
</p>
572572
<p className="mt-1 text-[10px] leading-4 text-[var(--text-tertiary)]">
573573
Turn on {label} to keep playback controls available while you review or commit.
@@ -576,29 +576,31 @@ export function GitSidebarAddons() {
576576
</div>
577577
) : (
578578
<>
579-
{(isMinimized || pipActive) && (
580-
<PluginCompactState pluginId={activeEntry.id} pipActive={pipActive} />
581-
)}
579+
{/* Compact controls — always visible when enabled */}
580+
<PluginCompactState pluginId={activeEntry.id} pipActive={pipActive} />
582581

583-
{shouldMountInline ? (
582+
{/* Full inline player — hidden when minimized or popped out */}
583+
{shouldMountInline && (
584584
<div
585585
className={`transition-[height,opacity] duration-200 ${
586586
isMinimized
587587
? 'h-0 overflow-hidden pointer-events-none opacity-0'
588-
: 'h-[360px]'
588+
: 'min-h-[200px] max-h-[400px]'
589589
}`}
590590
aria-hidden={isMinimized}
591591
>
592592
<Comp />
593593
</div>
594-
) : pipActive ? (
594+
)}
595+
596+
{/* PiP active notice */}
597+
{pipActive && !shouldMountInline && (
595598
<div className="px-3 pb-3">
596599
<div className="rounded-2xl border border-[var(--border)] bg-[var(--bg)] px-3 py-2.5 text-[10px] text-[var(--text-tertiary)]">
597-
{label} is open in picture-in-picture. The quick controls above stay active
598-
here.
600+
{label} is open in picture-in-picture. Controls above stay active here.
599601
</div>
600602
</div>
601-
) : null}
603+
)}
602604
</>
603605
)}
604606
</CardShell>

components/plan-view.tsx

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export interface PlanStep {
1515
interface Props {
1616
steps: PlanStep[]
1717
onApprove?: () => void
18-
onSkip?: () => void
18+
onReject?: () => void
1919
onStepToggle?: (stepId: string) => void
2020
interactive?: boolean
2121
title?: string
@@ -24,7 +24,7 @@ interface Props {
2424
export function PlanView({
2525
steps,
2626
onApprove,
27-
onSkip,
27+
onReject,
2828
onStepToggle,
2929
interactive = false,
3030
title,
@@ -213,24 +213,36 @@ export function PlanView({
213213
</div>
214214

215215
{/* Action bar — only shown for interactive plans */}
216-
{interactive && !allDone && (
216+
{interactive && !allDone && !hasRunning && (
217217
<div className="flex items-center justify-between px-4 py-2.5 bg-[var(--bg-secondary)] border-t border-[var(--border)]">
218218
<button
219-
onClick={onSkip}
220-
className="text-[10px] text-[var(--text-tertiary)] hover:text-[var(--text-secondary)] transition-colors cursor-pointer"
219+
onClick={onReject}
220+
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[10px] font-medium text-[var(--text-tertiary)] hover:text-[var(--text-secondary)] hover:bg-[var(--bg-subtle)] transition-all cursor-pointer"
221221
>
222-
Skip All
222+
<Icon icon="lucide:pencil" width={11} height={11} />
223+
Edit Plan
223224
</button>
224225
<button
225226
onClick={onApprove}
226227
className="flex items-center gap-1.5 px-4 py-1.5 rounded-lg text-[10px] font-semibold bg-[var(--brand)] text-[var(--brand-contrast)] hover:opacity-90 transition-opacity cursor-pointer shadow-[0_1px_3px_rgba(0,0,0,0.2)]"
228+
style={{ minHeight: 36, minWidth: 100 }}
227229
>
228230
<Icon icon="lucide:play" width={11} height={11} />
229-
Continue
231+
Execute Plan
230232
</button>
231233
</div>
232234
)}
233235

236+
{/* Executing state */}
237+
{hasRunning && (
238+
<div className="flex items-center justify-between px-4 py-2.5 bg-[var(--bg-secondary)] border-t border-[var(--border)]">
239+
<div className="flex items-center gap-1.5">
240+
<Icon icon="lucide:loader" width={12} height={12} className="text-[var(--brand)] animate-spin" />
241+
<span className="text-[10px] font-medium text-[var(--brand)]">Executing plan…</span>
242+
</div>
243+
</div>
244+
)}
245+
234246
{/* Success state */}
235247
{allDone && (
236248
<div className="flex items-center gap-2 px-4 py-2.5 bg-[color-mix(in_srgb,var(--color-additions)_6%,transparent)] border-t border-[var(--border)]">

components/plugins/youtube/youtube-player.tsx

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -505,19 +505,30 @@ export function YouTubePlayer() {
505505

506506
{/* Footer */}
507507
{current && (
508-
<div className="flex items-center h-7 px-2.5 border-t border-[var(--border)] shrink-0 gap-1.5">
508+
<div className="flex items-center h-8 px-2.5 border-t border-[var(--border)] shrink-0 gap-1.5">
509+
<button
510+
onClick={() => {
511+
sendPlayerCommand(isPlaying ? 'pauseVideo' : 'playVideo')
512+
setIsPlaying((prev) => !prev)
513+
}}
514+
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-lg bg-[color-mix(in_srgb,#FF0000_12%,transparent)] text-[#FF0000] transition hover:bg-[color-mix(in_srgb,#FF0000_22%,transparent)]"
515+
title={isPlaying ? 'Pause' : 'Play'}
516+
aria-label={isPlaying ? 'Pause' : 'Play'}
517+
>
518+
<Icon icon={isPlaying ? 'lucide:pause' : 'lucide:play'} width={10} height={10} />
519+
</button>
509520
<Icon
510-
icon={current.type === 'playlist' ? 'lucide:list-music' : 'lucide:play'}
521+
icon={current.type === 'playlist' ? 'lucide:list-music' : 'lucide:film'}
511522
width={9}
512523
height={9}
513-
className="text-[var(--text-disabled)]"
524+
className="text-[var(--text-disabled)] shrink-0"
514525
/>
515-
<span className="text-[8px] text-[var(--text-disabled)] ml-1 truncate">
526+
<span className="text-[8px] text-[var(--text-disabled)] truncate flex-1 min-w-0">
516527
{current.label}
517528
</span>
518529
<button
519530
onClick={toggleMute}
520-
className="ml-2 p-0.5 rounded hover:bg-[var(--bg-subtle)] text-[var(--text-disabled)] hover:text-[var(--text-secondary)] cursor-pointer"
531+
className="p-0.5 rounded hover:bg-[var(--bg-subtle)] text-[var(--text-disabled)] hover:text-[var(--text-secondary)] cursor-pointer"
521532
title={muted || volume === 0 ? 'Unmute' : 'Mute'}
522533
>
523534
<Icon
@@ -542,10 +553,9 @@ export function YouTubePlayer() {
542553
aria-label="YouTube volume"
543554
/>
544555
<span className="text-[8px] text-[var(--text-disabled)] w-7 text-right">{volume}%</span>
545-
<div className="flex-1" />
546556
<button
547557
onClick={clearCurrent}
548-
className="text-[8px] text-[var(--text-disabled)] hover:text-[var(--text-secondary)] cursor-pointer"
558+
className="text-[8px] text-[var(--text-disabled)] hover:text-[var(--text-secondary)] cursor-pointer shrink-0"
549559
>
550560
Clear
551561
</button>

components/sidebar-plugin-slot.tsx

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,8 @@ export function SidebarPluginSlot() {
7373
const delta = ev.clientY - startY
7474
const deltaRatio = delta / totalHeight
7575
const newTop = Math.max(
76-
0.15,
77-
Math.min(topRatio + bottomRatio - 0.15, topRatio + deltaRatio),
76+
0.2,
77+
Math.min(topRatio + bottomRatio - 0.2, topRatio + deltaRatio),
7878
)
7979
const newBottom = topRatio + bottomRatio - newTop
8080
setRatios((prev) => ({ ...prev, [topId]: newTop, [bottomId]: newBottom }))
@@ -130,7 +130,7 @@ export function SidebarPluginSlot() {
130130
<div className="shrink-0 border-b border-[var(--border)] px-3 py-2">
131131
<div className="flex items-center justify-between mb-1.5">
132132
<span className="text-[10px] font-semibold uppercase tracking-wider text-[var(--text-tertiary)]">
133-
Add-ons
133+
Widgets
134134
</span>
135135
</div>
136136
<div className="flex flex-col gap-1">
@@ -179,7 +179,7 @@ export function SidebarPluginSlot() {
179179
{enabledSorted.length === 0 && (
180180
<div className="flex-1 flex items-center justify-center p-4">
181181
<span className="text-xs text-[var(--text-disabled)] text-center">
182-
No add-ons enabled
182+
No widgets enabled
183183
</span>
184184
</div>
185185
)}
@@ -188,7 +188,11 @@ export function SidebarPluginSlot() {
188188
const ratio = ratios[e.id] ?? defaultRatio
189189
const meta = PLUGIN_META[e.id]
190190
return (
191-
<div key={e.id} className="flex flex-col min-h-0" style={{ flex: `${ratio} 1 0%` }}>
191+
<div
192+
key={e.id}
193+
className="flex flex-col"
194+
style={{ flex: `${ratio} 1 0%`, minHeight: '80px' }}
195+
>
192196
{i > 0 && (
193197
<div
194198
className="h-[5px] shrink-0 cursor-row-resize group/divider relative z-10"
@@ -198,14 +202,15 @@ export function SidebarPluginSlot() {
198202
<div className="absolute inset-x-2 top-1/2 -translate-y-1/2 h-[2px] rounded-full bg-[var(--text-disabled)] opacity-0 group-hover/divider:opacity-30 group-hover/divider:bg-[var(--brand)] transition-all" />
199203
</div>
200204
)}
201-
<div className="flex-1 min-h-0 overflow-hidden relative group/plugin">
205+
<div className="flex-1 min-h-0 overflow-hidden relative">
202206
{e.id === 'youtube-player' && (
203207
<button
204208
onClick={() => setPipPluginId(e.id)}
205-
className="absolute top-1 right-1 z-10 p-1 rounded-md bg-[var(--bg-elevated)]/80 backdrop-blur-sm text-[var(--text-disabled)] hover:text-[var(--text-secondary)] hover:bg-[var(--bg-subtle)] cursor-pointer opacity-0 group-hover/plugin:opacity-100 transition-opacity"
206-
title={`Pop out ${meta?.label ?? 'plugin'}`}
209+
className="absolute top-1 right-1 z-10 flex h-6 w-6 items-center justify-center rounded-md bg-[color-mix(in_srgb,var(--bg-elevated)_85%,transparent)] backdrop-blur-sm text-[var(--text-disabled)] hover:text-[var(--text-secondary)] hover:bg-[var(--bg-subtle)] cursor-pointer transition-colors"
210+
title={`Pop out ${meta?.label ?? 'widget'}`}
211+
aria-label={`Pop out ${meta?.label ?? 'widget'}`}
207212
>
208-
<Icon icon="lucide:picture-in-picture-2" width={12} height={12} />
213+
<Icon icon="lucide:picture-in-picture-2" width={11} height={11} />
209214
</button>
210215
)}
211216
<C />

lib/chat-stream.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,18 @@
44
*/
55

66
import { parseEditProposals, type EditProposal } from '@/lib/edit-parser'
7+
import { parsePlanSteps, isPlanContent } from '@/lib/plan-parser'
78
import { diffEngine } from '@/lib/streaming-diff'
89
import { emit } from '@/lib/events'
910

1011
export interface ChatMessage {
1112
id: string
1213
role: 'user' | 'assistant' | 'system'
13-
type?: 'text' | 'edit' | 'error' | 'tool' | 'status' | 'cancelled'
14+
type?: 'text' | 'edit' | 'error' | 'tool' | 'status' | 'cancelled' | 'plan'
1415
content: string
1516
timestamp: number
1617
editProposals?: EditProposal[]
18+
planSteps?: import('@/lib/plan-parser').ParsedPlanStep[]
1719
images?: Array<{ name: string; dataUrl: string }>
1820
}
1921

@@ -221,15 +223,22 @@ export function handleChatEvent(
221223
diffEngine.finalizeAll()
222224
emit('show-inline-diff', { proposals: editProposals })
223225
}
226+
const planSteps = isPlanContent(text) ? parsePlanSteps(text) : undefined
227+
const msgType = editProposals.length > 0
228+
? ('edit' as const)
229+
: planSteps
230+
? ('plan' as const)
231+
: ('text' as const)
224232
callbacks.setMessages((msgs) => [
225233
...msgs,
226234
{
227235
id: crypto.randomUUID(),
228236
role: 'assistant' as const,
229-
type: editProposals.length > 0 ? ('edit' as const) : ('text' as const),
237+
type: msgType,
230238
content: text,
231239
timestamp: Date.now(),
232240
editProposals: editProposals.length > 0 ? editProposals : undefined,
241+
planSteps,
233242
},
234243
])
235244
emit('agent-reply')

0 commit comments

Comments
 (0)