Copilot SDK TUI Harness Development Skill
Expert skill for developing the Copilot SDK TUI Harness—a terminal user interface that wraps the GitHub Copilot SDK with an event-driven, plugin-based architecture.
Project Identity
Name: Copilot SDK TUI Harness (copilot-anvil)
Stack: TypeScript + OpenTUI/React + @github/copilot-sdk
Runtime: Bun 1.0+
Architecture: Event-driven with strict UI ↔ SDK decoupling
Core Principles
1. Strict Layer Separation
UI Layer (OpenTUI/React)
↕ HarnessEvent / UIAction
Harness (Orchestrator)
↕ Internal callbacks
CopilotSessionAdapter
↕
@github/copilot-sdk
CRITICAL: The UI layer never imports @github/copilot-sdk directly. All SDK interaction flows through the adapter and harness layers.
2. Event-Driven Communication
- •Harness → UI:
HarnessEventtypes (run.started, assistant.delta, log, etc.) - •UI → Harness:
UIActiontypes (submit.prompt, cancel) - •No direct function calls between layers—everything flows through events
3. Plugin-Ready Architecture
The system is designed for extensibility via plugins that can register:
- •Tools (callable by the assistant)
- •Commands (user-invokable actions)
- •Panes (UI components)
- •State slices (additional state management)
Architecture Deep Dive
Directory Structure
src/
├── index.tsx # Entry point, bootstraps app
├── copilot/
│ └── CopilotSessionAdapter.ts # ONLY place that imports SDK
├── harness/
│ ├── Harness.ts # Orchestrator, state manager
│ ├── events.ts # Event type definitions
│ └── plugins.ts # Plugin system interfaces
├── ui/
│ ├── App.tsx # Main OpenTUI app component
│ ├── theme.ts # Theme configuration
│ ├── syntaxTheme.ts # Syntax highlighting theme
│ └── panes/
│ ├── ChatPane.tsx # Conversation transcript
│ ├── InputBar.tsx # Prompt input with image support
│ ├── Sidebar.tsx # Sidebar container
│ ├── TasksPane.tsx # Task tracking display
│ ├── ContextPane.tsx # Context information
│ ├── SubagentsPane.tsx # Subagent monitoring
│ ├── FilesModifiedPane.tsx # Git modified files
│ ├── PlanPane.tsx # Plan viewer
│ ├── LogsPane.tsx # System logs
│ ├── StartScreen.tsx # Welcome screen
│ ├── ModelSelector.tsx # Model selection modal
│ ├── SessionSwitcher.tsx # Session management
│ ├── SkillsPane.tsx # Skills browser
│ ├── QuestionModal.tsx # Interactive questions
│ ├── ConfirmModal.tsx # Confirmation dialogs
│ ├── CommandModal.tsx # Ephemeral run display
│ └── DebugOverlay.tsx # Debug overlay
├── commands/ # Slash command system
│ └── CommandLoader.ts # Command registry
└── utils/ # Shared utilities
├── git.ts # Git operations
├── gitDiff.ts # Diff utilities
└── stderrCapture.ts # Error capture
Key Files and Responsibilities
src/copilot/CopilotSessionAdapter.ts
Purpose: Wraps Copilot SDK, translates SDK events to HarnessEvents
Responsibilities:
- •Initialize CopilotClient
- •Create/manage CopilotSession (with
streaming: true) - •Subscribe to SDK events and emit corresponding HarnessEvents
- •Handle prompt submission via session.sendAndWait()
- •Must NOT contain UI code or import UI components
Key Pattern:
// SDK event → Harness event translation
session.on('assistant.message_delta', (event) => {
this.emit({
type: 'assistant.delta',
runId: this.currentRunId,
text: event.content
});
});
src/harness/Harness.ts
Purpose: Central orchestrator and state manager
Responsibilities:
- •Maintain UI transcript (for display only—SDK has true memory)
- •Own event bus and lifecycle state
- •Handle UIAction dispatch from UI
- •Support plugin registration and management
- •Coordinate between adapter and UI
Key Patterns:
- •State is immutable; updates create new state objects
- •Events are emitted synchronously to all listeners
- •Plugins hook into lifecycle via
onEvent()
src/harness/events.ts
Purpose: Define all event and action types
Core HarnessEvent Types:
| Event | Payload | When Emitted |
|---|---|---|
run.started | { runId, createdAt } | Prompt submitted, SDK begins processing |
assistant.delta | { runId, text } | Streaming token received from SDK |
assistant.message | { runId, message } | Complete assistant response ready |
reasoning.delta | { runId, reasoningId, text } | Reasoning token received (o1 models) |
reasoning.message | { runId, reasoningId, content } | Complete reasoning content |
run.finished | { runId, createdAt } | Run completed successfully |
run.cancelled | { runId, createdAt } | User cancelled with Ctrl+C |
tool.started | { runId, toolCallId, toolName, arguments } | Tool execution begins |
tool.progress | { runId, toolCallId, message } | Tool progress update |
tool.completed | { runId, toolCallId, success, output, error } | Tool execution completes |
subagent.started | { runId, toolCallId, agentName, ... } | Subagent invoked |
subagent.completed | { runId, toolCallId, agentName } | Subagent finished |
subagent.failed | { runId, toolCallId, agentName, error } | Subagent failed |
skill.invoked | { runId, name, path } | Skill invoked |
intent.updated | { runId, intent } | Agent intent updated |
todo.updated | { runId, todos } | Task list updated |
plan.updated | { content } | Plan content updated |
turn.started | { runId, turnId } | New turn begins |
turn.ended | { runId, turnId } | Turn completes |
question.requested | { requestId, question, choices, allowFreeform } | Agent asks question |
question.answered | { requestId, answer, wasFreeform } | User answers question |
session.switched | { sessionId, sessionName, transcript } | Session changed |
session.created | { sessionId, sessionName } | New session created |
session.list.updated | { sessions } | Session list refreshed |
model.changed | { model } | AI model changed |
usage.info | { tokenLimit, currentTokens, messagesLength } | Token usage info |
quota.info | { remainingPremiumRequests, consumedRequests } | Quota info |
log | { runId?, level, message, data? } | System or plugin log message |
UIAction Types:
| Action | Payload | Effect |
|---|---|---|
submit.prompt | { text, images? } | Send prompt to Copilot SDK (with optional images) |
cancel | {} | Abort current run |
change.model | { modelId } | Switch AI model |
answer.question | { requestId, answer, wasFreeform } | Answer agent question |
session.new | {} | Create new session |
session.switch | { sessionId } | Switch to session |
session.refresh | {} | Refresh session list |
ephemeral.close | {} | Close ephemeral run modal |
src/harness/plugins.ts
Purpose: Define plugin system interfaces
Plugin Interface:
interface HarnessPlugin {
name: string;
register(ctx: PluginContext): void;
onEvent?(event: HarnessEvent): void;
}
interface PluginContext {
tools: ToolRegistry;
commands: CommandRegistry;
panes: PaneRegistry;
state: StateRegistry;
emit(event: HarnessEvent): void;
}
Key Concept: Plugins receive a context object during registration and can subscribe to all harness events.
src/ui/App.tsx
Purpose: Main OpenTUI application component
Responsibilities:
- •Layout definition (chat + logs + input)
- •Subscribe to harness events and update local state
- •Dispatch UIActions in response to user input
- •Render keybind hints and status bar
Key Patterns:
- •Use React hooks (useState, useEffect) for local UI state
- •Never directly manipulate harness state
- •All harness communication via events/actions
src/ui/panes/ChatPane.tsx
Purpose: Display conversation transcript with streaming support
Responsibilities:
- •Render user and assistant messages
- •Show streaming draft during active run
- •Display tool calls inline in transcript
- •Handle scrolling and layout
- •Support syntax highlighting for code blocks
Key Pattern: Separate committed messages from streaming buffer:
{transcript.map(item =>
item.kind === "message" ? <Message {...item} /> : <ToolCall {...item} />
)}
{streamingBuffer && <StreamingMessage text={streamingBuffer} />}
src/ui/panes/InputBar.tsx
Purpose: Handle prompt input with image attachment support
Responsibilities:
- •Provide text input field
- •Dispatch
submit.promptaction on Enter - •Disable input during active run
- •Handle image attachment (Ctrl+I)
- •Show input hints
src/ui/panes/Sidebar.tsx
Purpose: Container for sidebar panels
Responsibilities:
- •Layout sidebar panes vertically
- •Manage space allocation between panes
- •Show/hide panes based on content
src/ui/panes/TasksPane.tsx
Purpose: Display active tasks
Responsibilities:
- •Show running, completed, and failed tasks
- •Display task timing and status
- •Update in real-time via events
src/ui/panes/ContextPane.tsx
Purpose: Display current context information
Responsibilities:
- •Show git information (branch, repo, commit)
- •Display active tools
- •Show token usage and quota
src/ui/panes/SubagentsPane.tsx
Purpose: Monitor subagent execution
Responsibilities:
- •Display active and completed subagents
- •Show subagent status and timing
- •Track subagent failures
src/ui/panes/FilesModifiedPane.tsx
Purpose: Show git modified files
Responsibilities:
- •List unstaged and staged changes
- •Display file change types (modified, added, deleted)
- •Update periodically from git status
src/ui/panes/PlanPane.tsx
Purpose: Display execution plan
Responsibilities:
- •Show current plan content
- •Support markdown rendering
- •Track plan updates
src/ui/panes/ModelSelector.tsx
Purpose: Model selection modal
Responsibilities:
- •Display available models
- •Allow model switching
- •Show current model
- •Handle keyboard navigation
src/ui/panes/SessionSwitcher.tsx
Purpose: Session management modal
Responsibilities:
- •List all sessions
- •Allow session switching
- •Support creating new sessions
- •Show session metadata
src/ui/panes/SkillsPane.tsx
Purpose: Skills browser
Responsibilities:
- •List available skills
- •Show skill descriptions
- •Allow skill invocation
- •Display skill metadata
src/ui/panes/QuestionModal.tsx
Purpose: Interactive user questions
Responsibilities:
- •Display agent questions
- •Show choices (if provided)
- •Support freeform input
- •Handle keyboard navigation
src/ui/panes/CommandModal.tsx
Purpose: Ephemeral run display
Responsibilities:
- •Show ephemeral run output
- •Display status and progress
- •Allow closing modal
src/ui/panes/StartScreen.tsx
Purpose: Welcome screen
Responsibilities:
- •Show before first interaction
- •Display keybind hints
- •Provide getting started info
Event Flow Patterns
Typical Prompt Submission Flow
1. User types in InputBar, presses Enter
2. InputBar dispatches UIAction({ type: "submit.prompt", text })
3. Harness.dispatch() receives action
4. Harness emits HarnessEvent({ type: "run.started", runId, createdAt })
5. Harness calls CopilotSessionAdapter.sendPrompt(text)
6. Adapter calls session.sendAndWait({ prompt: text })
7. SDK streams response tokens
8. For each token:
a. Adapter receives SDK event
b. Adapter emits HarnessEvent({ type: "assistant.delta", runId, text })
c. Harness updates streaming buffer
d. UI re-renders with updated buffer
9. SDK completes
10. Adapter emits HarnessEvent({ type: "assistant.message", runId, message })
11. Harness commits buffer to transcript
12. Harness emits HarnessEvent({ type: "run.finished", runId, createdAt })
13. UI shows completed message, re-enables input
Cancellation Flow
1. User presses Ctrl+C during run
2. UI dispatches UIAction({ type: "cancel" })
3. Harness.dispatch() receives action
4. Harness tells adapter to cancel
5. Adapter stops processing deltas (best-effort)
6. Adapter emits HarnessEvent({ type: "run.cancelled", runId, createdAt })
7. Harness clears streaming buffer, resets to idle
8. UI re-enables input
Development Guidelines
When Adding a New Feature
1. Define Events First
- •Add new event types to
src/harness/events.ts - •Define clear contracts (what data, when emitted)
2. Implement in Layers
- •If SDK-related: extend
CopilotSessionAdapter - •If state/logic: extend
Harness - •If UI: add/modify components in
src/ui/
3. Maintain Separation
- •Never import SDK in UI
- •Keep adapter simple—just translation
- •Put logic in harness, not adapter or UI
When Adding a Plugin
1. Define the plugin object:
const myPlugin: HarnessPlugin = {
name: "my-feature",
register(ctx) {
// Register tools, commands, etc.
ctx.tools.register("myTool", async (args) => {
return { result: "..." };
});
},
onEvent(event) {
// React to lifecycle events
if (event.type === "run.started") {
console.log("Run started!");
}
}
};
2. Register with harness:
harness.use(myPlugin);
3. Test integration:
- •Verify tools are callable by assistant
- •Ensure events are received
- •Check for conflicts with existing plugins
When Adding a New Pane
1. Create component: src/ui/panes/NewPane.tsx
2. Design state needs:
- •What events does it subscribe to?
- •What actions does it dispatch?
- •What data does it display?
3. Add to layout: Update src/ui/App.tsx
- •Import the pane
- •Add to the appropriate section (main area, sidebar, or modal)
- •Connect to harness state
4. Handle keyboard shortcuts (if needed):
- •Add keybind in
App.tsx - •Update state to show/hide pane
5. (Future) Register via plugin: Use ctx.panes.register()
Existing panes for reference:
- •Main area:
ChatPane,InputBar - •Sidebar:
TasksPane,ContextPane,SubagentsPane,FilesModifiedPane,PlanPane - •Modals:
ModelSelector,SessionSwitcher,SkillsPane,QuestionModal,ConfirmModal,CommandModal - •Overlays:
StartScreen,DebugOverlay
When Modifying SDK Integration
ONLY edit src/copilot/CopilotSessionAdapter.ts
Common tasks:
- •Change session options (model, streaming, etc.)
- •Add new SDK event subscriptions
- •Adjust prompt formatting
- •Handle new SDK capabilities
Never:
- •Import SDK elsewhere
- •Put business logic in adapter
- •Directly manipulate UI from adapter
Common Patterns
Pattern: Buffered Streaming
// In Harness
private streamingBuffer = "";
onDelta(event: AssistantDeltaEvent) {
this.streamingBuffer += event.text;
this.notifyUI();
}
onComplete(event: AssistantMessageEvent) {
this.transcript.push({
role: "assistant",
content: this.streamingBuffer,
createdAt: new Date()
});
this.streamingBuffer = "";
this.notifyUI();
}
Pattern: Idempotent Event Handling
// In Plugin
onEvent(event: HarnessEvent) {
if (event.type === "run.started") {
// Always safe to call, even if called multiple times
this.resetState();
}
}
Pattern: Async Tool Registration
// In Plugin
register(ctx: PluginContext) {
ctx.tools.register("fetchData", async (args) => {
const data = await fetch(args.url);
return await data.json();
});
}
Testing Strategies
Manual Testing
bun run dev
Test cases:
- •Send a prompt, verify streaming display
- •Send multiple prompts, verify session continuity
- •Press Ctrl+C during streaming, verify cancellation
- •Trigger error (bad auth), verify error handling
- •Check logs pane shows lifecycle events
Integration Testing
- •Mock CopilotSessionAdapter
- •Emit events manually
- •Verify harness state updates
- •Verify UI renders correctly
Unit Testing
- •Test event type definitions
- •Test plugin registration logic
- •Test utility functions
Debugging
Enable verbose logging
// In Harness or Adapter
this.emit({
type: "log",
level: "info",
message: "Debug: streaming delta",
data: { text, runId }
});
Check event flow
Add logging to:
- •
Harness.dispatch()— see incoming actions - •
Adapterevent handlers — see SDK events - •UI
useEffecthooks — see state updates
Inspect state
Use OpenTUI devtools or add temporary UI to display:
- •Current harness state
- •Active run ID
- •Streaming buffer content
- •Plugin registry contents
Performance Considerations
Streaming Updates
- •Debounce UI updates if delta rate is very high
- •Use React memoization for message list
- •Limit log pane to last N entries
Event Listeners
- •Remove listeners on component unmount
- •Use weak references if holding onto event listeners long-term
Plugin Load Time
- •Keep
register()fast—defer heavy init to first use - •Don't block on network calls during registration
Common Pitfalls
❌ Importing SDK in UI
// WRONG - never do this
import { CopilotClient } from "@github/copilot-sdk";
Fix: Use harness events and actions instead
❌ Putting Logic in Adapter
// WRONG - adapter should not have business logic
adapter.sendPrompt = (text) => {
if (text.includes("secret")) {
// Don't do this here!
throw new Error("Forbidden");
}
session.sendAndWait({ prompt: text });
}
Fix: Put validation in harness or plugin
❌ Mutating Harness State from UI
// WRONG - never mutate harness state directly harness.state.transcript.push(newMessage);
Fix: Dispatch an action, let harness update its own state
❌ Blocking Event Handlers
// WRONG - don't block the event loop
onEvent(event) {
if (event.type === "run.started") {
// This blocks!
const result = syncExpensiveOperation();
}
}
Fix: Use async/await or defer work to next tick
Scripts and Commands
| Command | Purpose |
|---|---|
bun run dev | Start TUI in development mode |
bun run start | Same as dev (alias) |
bun install | Install dependencies |
Dependencies
Production
- •
@github/copilot-sdk— Core SDK for agent runtime - •
@opentui/core— Terminal UI framework (core) - •
@opentui/react— React reconciler for OpenTUI - •
react— React library - •
diff— Diff utility (future use)
Development
- •
typescript— TypeScript compiler - •
@types/node— Node.js type definitions - •
@types/react— React type definitions - •
@types/diff— Diff type definitions
Configuration Files
- •
package.json— Project manifest, scripts, dependencies - •
tsconfig.json— TypeScript compiler options - •
bun.lock— Bun lockfile
Resources
- •Full requirements:
docs/REQUIREMENTS.md - •Agent docs:
AGENTS.md - •Main README:
README.md
Quick Reference: File → Responsibility
| File | What to Change |
|---|---|
CopilotSessionAdapter.ts | SDK integration, event translation |
Harness.ts | State management, orchestration, sessions |
events.ts | Add new event/action types |
plugins.ts | Extend plugin system |
App.tsx | Layout, top-level UI structure |
ChatPane.tsx | Conversation display with tool calls |
LogsPane.tsx | Log display (optional, not in default layout) |
Sidebar.tsx | Sidebar container and layout |
TasksPane.tsx | Task tracking display |
ContextPane.tsx | Context information display |
SubagentsPane.tsx | Subagent monitoring |
FilesModifiedPane.tsx | Git status display |
PlanPane.tsx | Plan viewer |
ModelSelector.tsx | Model selection modal |
SessionSwitcher.tsx | Session management |
SkillsPane.tsx | Skills browser |
QuestionModal.tsx | Interactive questions |
theme.ts | Theme configuration |
syntaxTheme.ts | Syntax highlighting |
InputBar.tsx | Prompt input with image support |
Troubleshooting
"Copilot not authenticated"
npm install -g @github/copilot-cli copilot auth login
Streaming not working
- •Check
streaming: truein session options - •Verify adapter subscribes to
assistant.message_delta - •Check harness emits
assistant.deltaevents
Cancellation not working
- •SDK may not support true abort
- •Current approach: ignore subsequent deltas, reset state
- •Session remains valid for next prompt
Plugin not receiving events
- •Verify plugin registered:
harness.use(plugin) - •Check
onEventmethod exists and has correct signature - •Add logging to verify events are being emitted
UI not updating
- •Check component subscribes to harness events
- •Verify state changes trigger re-render
- •Use React DevTools to inspect component state
Future Enhancements (Planned)
Architecture supports these additions:
- •Tasks: ✅ Implemented — Task tracking and status display
- •Memory: Memory provider interface, context injection
- •Git & Diffs: ✅ Partially implemented — Diff viewing, modified files; approval gating pending
- •GitHub PR: PR tools, PR resources, PR pane
- •File Browser: File selection pane, context injection
- •Sessions: ✅ Implemented — Multi-session support with persistence
- •Skills: ✅ Implemented — Skills browser and invocation
- •Image Support: ✅ Implemented — Image attachment for vision models
- •Plans: ✅ Implemented — Plan tracking and display
- •Subagents: ✅ Implemented — Subagent monitoring and status
Summary
This skill provides comprehensive knowledge for working on the Copilot SDK TUI Harness project. Key takeaways:
- •Strict separation: UI never imports SDK
- •Event-driven: All communication via events/actions
- •Plugin-ready: Extensibility built into core
- •Three layers: UI → Harness → Adapter → SDK
- •TypeScript + OpenTUI: Strongly typed, terminal-first
When developing, always consider: Which layer does this belong in? What events does it produce/consume? How does it fit into the plugin system?