Skip to content
Merged
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
73 changes: 33 additions & 40 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"test:watch": "vitest"
},
"dependencies": {
"@github/copilot-sdk": "^0.1.0",
"@github/copilot-sdk": "^0.1.24",
"ink": "^5.1.0",
"ink-select-input": "^6.0.0",
"ink-spinner": "^5.0.0",
Expand Down
87 changes: 76 additions & 11 deletions src/screens/execute.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef } from 'react';
import { Box, Text, useInput } from 'ink';
import type { Plan, Task } from '../models/plan.js';
import { executePlan } from '../services/executor.js';
import type { ExecutionOptions, ExecutionHandle } from '../services/executor.js';
import type { ExecutionOptions, ExecutionHandle, SessionEventWithTask } from '../services/executor.js';
import { savePlan, summarizePlan } from '../services/persistence.js';
import { computeBatches } from '../utils/dependency-graph.js';
import Spinner from '../components/spinner.js';
Expand Down Expand Up @@ -46,6 +46,8 @@ export default function ExecuteScreen({
const [runCount, setRunCount] = useState(0); // incremented to re-trigger execution
const execHandleRef = useRef<ExecutionHandle | null>(null);
const [summarized, setSummarized] = useState('');
const [sessionEvents, setSessionEvents] = useState<SessionEventWithTask[]>([]);
const [taskContexts, setTaskContexts] = useState<Record<string, { cwd?: string; repository?: string; branch?: string }>>({});

Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sessionEvents and taskContexts are kept across retries / reruns (runCount changes) and are not cleared when restarting execution. This can show stale cwd/repo/branch from a previous run or from before a task retry. Consider resetting these states when a new run starts, and clearing per-task context when retrying that task.

Suggested change
useEffect(() => {
// When a new execution run starts, clear any session events and per-task
// contexts from previous runs to avoid showing stale cwd/repo/branch data.
if (runCount === 0) {
return;
}
setSessionEvents([]);
setTaskContexts({});
}, [runCount]);

Copilot uses AI. Check for mistakes.
const { batches } = computeBatches(plan.tasks);
// Total display batches: init batch (index 0) + real batches
Expand Down Expand Up @@ -195,6 +197,32 @@ export default function ExecuteScreen({
}
// Otherwise stay on execute screen — user can press 'r' to retry
},
onSessionEvent: (eventWithTask) => {
const { event, taskId } = eventWithTask;

// Only store session events that we actually render / use, and keep history bounded
if (event.type === 'session.context_changed') {
const { cwd, repository, branch } = event.data;
setSessionEvents((prev) => {
const updated = [...prev, eventWithTask];
return updated.slice(-100); // Keep last 100 events
});
setTaskContexts((prev) => ({
...prev,
[taskId]: { cwd, repository, branch },
}));
} else if (event.type === 'session.start' && event.data.context) {
const { cwd, repository, branch } = event.data.context;
setSessionEvents((prev) => {
const updated = [...prev, eventWithTask];
return updated.slice(-100); // Keep last 100 events
});
setTaskContexts((prev) => ({
...prev,
[taskId]: { cwd, repository, branch },
}));
}
},
}, execOptions);

execHandleRef.current = handle;
Expand Down Expand Up @@ -318,18 +346,29 @@ export default function ExecuteScreen({
const isSelected = i === selectedTaskIndex;
const icon = STATUS_ICON[task.status] ?? '?';
const color = STATUS_COLOR[task.status] ?? 'gray';
const context = taskContexts[task.id];
return (
<Box key={task.id}>
<Text color={isSelected ? 'white' : 'gray'}>{isSelected ? '❯ ' : ' '}</Text>
<Text color={color}>{icon} </Text>
<Text color={isSelected ? 'white' : color} bold={isSelected}>
{task.id}
</Text>
<Text color="gray"> — {task.title}</Text>
{task.status === 'in_progress' && (
<Text color="yellow"> </Text>
<Box key={task.id} flexDirection="column">
<Box>
<Text color={isSelected ? 'white' : 'gray'}>{isSelected ? '❯ ' : ' '}</Text>
<Text color={color}>{icon} </Text>
<Text color={isSelected ? 'white' : color} bold={isSelected}>
{task.id}
</Text>
<Text color="gray"> — {task.title}</Text>
{task.status === 'in_progress' && (
<Text color="yellow"> </Text>
)}
{task.status === 'in_progress' && <Spinner />}
</Box>
{context?.cwd && (
<Box marginLeft={4}>
<Text color="cyan" dimColor>📁 {context.cwd}</Text>
{context.repository && (
<Text color="blue" dimColor> ({context.repository})</Text>
)}
Comment on lines +364 to +369
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The task list context display omits branch even though it’s captured in taskContexts and the PR description example shows repo@branch. Consider including branch here (when present) to match the intended UI output.

Copilot uses AI. Check for mistakes.
</Box>
)}
{task.status === 'in_progress' && <Spinner />}
</Box>
);
})}
Expand Down Expand Up @@ -376,6 +415,32 @@ export default function ExecuteScreen({
</Box>
)}

{/* Context change events for selected task */}
{started && selectedTask && (() => {
const taskEvents = sessionEvents.filter(
(e) => e.taskId === selectedTask.id && e.event.type === 'session.context_changed'
);
if (taskEvents.length === 0) return null;

return (
<Box flexDirection="column" marginLeft={1} marginBottom={1}>
<Text color="cyan" bold>Context Changes:</Text>
{taskEvents.slice(-3).map((e) => {
if (e.event.type !== 'session.context_changed') return null;
const time = new Date(e.event.timestamp).toLocaleTimeString();
const { cwd, repository, branch } = e.event.data;
return (
<Box key={e.event.id}>
<Text color="gray">{time} </Text>
<Text color="cyan">→ {cwd}</Text>
{repository && <Text color="blue"> ({repository}{branch ? `@${branch}` : ''})</Text>}
</Box>
);
})}
</Box>
);
})()}

{/* Retry prompt when there are failures */}
{started && !executing && failedCount > 0 && (
<Box marginBottom={1}>
Expand Down
24 changes: 23 additions & 1 deletion src/services/copilot.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { CopilotClient } from '@github/copilot-sdk';
import type { SessionEvent } from '@github/copilot-sdk';
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { join } from 'node:path';
import { existsSync } from 'node:fs';
import type { ChatMessage } from '../models/plan.js';

// Re-export SessionEvent for use in other modules
export type { SessionEvent };

const SETTINGS_PATH = join(process.cwd(), '.planeteer', 'settings.json');

interface Settings {
Expand Down Expand Up @@ -108,6 +112,7 @@ export interface StreamCallbacks {
onDelta: (text: string) => void;
onDone: (fullText: string) => void;
onError: (error: Error) => void;
onSessionEvent?: (event: SessionEvent) => void;
}

export async function sendPrompt(
Expand Down Expand Up @@ -137,6 +142,17 @@ export async function sendPrompt(
let fullText = '';
let settled = false;

// Listen for session events if callback provided, but avoid forwarding
// high-volume delta events that are already handled by onDelta.
if (callbacks.onSessionEvent) {
session.on((event: SessionEvent) => {
if (event.type === 'assistant.message_delta') {
return;
}
callbacks.onSessionEvent?.(event);
});
}

session.on('assistant.message_delta', (event: { data: { deltaContent: string } }) => {
fullText += event.data.deltaContent;
callbacks.onDelta(event.data.deltaContent);
Expand Down Expand Up @@ -176,10 +192,15 @@ export async function sendPrompt(
export async function sendPromptSync(
systemPrompt: string,
messages: ChatMessage[],
options?: { timeoutMs?: number; onDelta?: (delta: string, fullText: string) => void },
options?: {
timeoutMs?: number;
onDelta?: (delta: string, fullText: string) => void;
onSessionEvent?: (event: SessionEvent) => void;
},
): Promise<string> {
const idleTimeoutMs = options?.timeoutMs ?? 120_000;
const onDelta = options?.onDelta;
const onSessionEvent = options?.onSessionEvent;

return new Promise((resolve, reject) => {
let settled = false;
Expand Down Expand Up @@ -235,6 +256,7 @@ export async function sendPromptSync(
reject(err);
}
},
onSessionEvent,
});
});
}
Loading