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
65 changes: 65 additions & 0 deletions .opencode/command/changelog-update.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
---
description: Analyzes recent git commits, determines version bump, and updates CHANGELOG.md following Keep a Changelog and Semantic Versioning guidelines.
subtask: true
---

Follow these steps to update the CHANGELOG.md file based on recent changes in the git history.

First, review the key guidelines from Keep a Changelog (https://keepachangelog.com/en/1.0.0/):

- Use CHANGELOG.md as the file name.
- Follow reverse chronological order with the latest version first.
- Each version entry: [version] followed by ISO 8601 date (YYYY-MM-DD).
- Include an [Unreleased] section at the top for upcoming changes.
- Sections: Added (new features), Changed (existing functionality), Deprecated, Removed, Fixed (bugs), Security (vulnerabilities).
- Best practices: Adhere to Semantic Versioning; keep human-readable; include release dates; make linkable; avoid empty sections; mark yanked releases.

Next, review Semantic Versioning 2.0.0 (https://semver.org/spec/v2.0.0.html):

- Version format: MAJOR.MINOR.PATCH (e.g., 1.2.3).
- Pre-release: Append -alpha.1 etc.; build metadata: +001 (ignored in precedence).
- Increment: MAJOR for incompatible API changes; MINOR for backward-compatible additions/deprecations; PATCH for backward-compatible bug fixes.
- Version 0.y.z for unstable initial development.
- Precedence: Compare MAJOR/MINOR/PATCH numerically; pre-releases lower than normal; compare pre-release identifiers lexically/numerically.

Now, analyze the project:

1. Get the current CHANGELOG.md content: @CHANGELOG.md

2. Identify the last released version from CHANGELOG.md or git tags: !git describe --tags --abbrev=0 || echo "0.0.0"

Let last_version = output of above.

3. Get commit messages since last version: !git log --pretty=format:"%s" ${last_version}..HEAD

If no commits, respond: "No changes since last version. No update needed."

4. Classify each commit message into categories (Added, Changed, Deprecated, Removed, Fixed, Security). Use conventional commit prefixes if present (feat: → Added, fix: → Fixed, breaking: → Changed with MAJOR bump).

5. Determine version bump:
- MAJOR if any breaking changes.
- MINOR if new features (Added) but no breaking.
- PATCH if only fixes/changes without new features or breaking.
- If pre-release needed, append -alpha.1 etc. (decide based on stability).

Compute next_version by incrementing from last_version accordingly.

6. Group changes under appropriate headings. Omit empty sections.

7. Get today's date: !date +%Y-%m-%d

8. Decide on release strategy:
- For unreleased changes: Add or update the [Unreleased] section at the top.
- For a new release: Create new section ## [next_version] - today's_date

Add the grouped changes to the appropriate section.

If [Unreleased] exists, incorporate or replace it.

9. Preserve existing CHANGELOG content, inserting the new section after the header or updating [Unreleased] as appropriate.

10. Output the full updated CHANGELOG.md content.

If $ARGUMENTS provided, use it as additional changes or override (e.g., /update-changelog "Added: new feature").

Ensure the update is accurate, concise, and follows the guidelines exactly.
54 changes: 54 additions & 0 deletions .opencode/command/commit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
---
description: Analyze git changes, determine optimal commit strategy, craft Conventional Commits messages, and commit changes autonomously
subtask: true
---

Please perform the following steps carefully to create high-quality git commits:

1. Run `git status` to examine the current repository state, including staged, unstaged, and untracked files.

2. Inspect the detailed changes:
- Staged changes: review the full `git diff --staged`
- Unstaged changes: review the full `git diff`
- Untracked files: list and review their contents if relevant

3. Deeply analyze the purpose, impact, and nature of all changes in the codebase, including staged, unstaged, and untracked files.

4. Determine the optimal commit strategy based on the analysis:
- Identify logical groups of changes (e.g., by feature, bug fix, refactor, documentation). Changes should be grouped if they are cohesive and achieve a single purpose.
- Decide whether to:
- Commit staged changes as-is if they form a complete, logical unit.
- Stage additional unstaged/untracked changes that belong to the same logical group as staged changes.
- Split changes into multiple commits if they represent distinct logical units (e.g., one for feat, one for fix).
- Stage and commit unstaged changes separately if no staged changes exist but changes are ready.
- Ignore or recommend ignoring irrelevant changes (e.g., temporary files).
- Prioritize small, atomic commits that are easy to review and revert.
- If no changes are ready or logical to commit, inform the user and suggest actions (e.g., staging specific files).

5. For each identified commit group:
- Stage the relevant files if not already staged (using `git add <files>` or `git add -A` if appropriate).
- Craft a detailed commit message that strictly follows the Conventional Commits specification (v1.0.0).

Before writing the message, carefully read and internalize the complete specification here:
https://www.conventionalcommits.org/en/v1.0.0/#specification

Key guidelines from the spec:
- Use the format: `<type>[optional scope]: <description>`
- Allowed types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert (and others if justified)
- Subject line: imperative tense, no capitalization, ≤50 characters
- Optional body: explain **what** and **why** (not how), wrap at 72 characters
- Optional footer: for BREAKING CHANGE, references to issues, etc.

Analyze the project to determine its type (e.g., CLI tool, library, web app, etc.). Distinguish between changes noticeable to end-users (e.g., new features, bug fixes in user-facing behavior) and internal changes (e.g., developer tools, refactors, documentation). Use 'feat' or 'fix' only for changes that are noticeable to the end-user. For internal code changes, enhancements to development workflows, or non-user-facing improvements that benefit developers but not end-users, use types like 'chore', 'refactor', 'docs', or similar.

Choose the most appropriate type and scope based on the changes. Make the message clear, professional, and informative for future readers (including changelogs and release notes).

- Commit the staged changes with the crafted message using `git commit -m "<message>"` (include body and footer in the message if needed, using newlines).

6. If multiple commits are needed, perform them sequentially, restaging as necessary after each commit.

7. After all commits, run `git status` again to confirm the repository state and summarize what was committed.

8. If any changes were not committed (e.g., not ready or irrelevant), explain why and suggest next steps.

Prioritize accuracy, completeness, and adherence to the Conventional Commits standard. Make decisions autonomously based on best practices for clean commit history.
6 changes: 3 additions & 3 deletions flake.lock

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

25 changes: 23 additions & 2 deletions src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { PTYServer } from './web/server/server.ts'
import open from 'open'

const ptyOpenClientCommand = 'pty-open-background-spy'
const ptyShowServerUrlCommand = 'pty-show-server-url'

export const PTYPlugin = async ({ client, directory }: PluginContext): Promise<PluginResult> => {
initPermissions(client, directory)
Expand All @@ -18,13 +19,29 @@ export const PTYPlugin = async ({ client, directory }: PluginContext): Promise<P

return {
'command.execute.before': async (input) => {
if (input.command !== ptyOpenClientCommand) {
if (input.command !== ptyOpenClientCommand && input.command !== ptyShowServerUrlCommand) {
return
}
if (ptyServer === undefined) {
ptyServer = await PTYServer.createServer()
}
open(ptyServer.server.url.origin)
if (input.command === ptyOpenClientCommand) {
open(ptyServer.server.url.origin)
} else if (input.command === ptyShowServerUrlCommand) {
const message = `PTY Sessions Web Interface URL: ${ptyServer.server.url.origin}`
await client.session.prompt({
path: { id: input.sessionID },
body: {
noReply: true,
parts: [
{
type: 'text',
text: message,
},
],
},
})
}
throw new Error('Command handled by PTY plugin')
},
tool: {
Expand All @@ -42,6 +59,10 @@ export const PTYPlugin = async ({ client, directory }: PluginContext): Promise<P
template: `This command will start the PTY Sessions Web Interface in your default browser.`,
description: 'Open PTY Sessions Web Interface',
}
input.command[ptyShowServerUrlCommand] = {
template: `This command will show the PTY Sessions Web Interface URL.`,
description: 'Show PTY Sessions Web Interface URL',
}
},
event: async ({ event }) => {
if (event.type === 'session.deleted') {
Expand Down
7 changes: 0 additions & 7 deletions src/web/client/components/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,13 +124,6 @@ export function App() {
Debug: {rawOutput.length} chars, active: {activeSession?.id || 'none'}, WS raw_data:{' '}
{wsMessageCount}, session_updates: {sessionUpdateCount}
</div>
<div data-testid="test-output" style={{ position: 'absolute', left: '-9999px' }}>
{rawOutput.split('\n').map((line, i) => (
<div key={i} className="output-line">
{line}
</div>
))}
</div>
</>
) : (
<div className="empty-state">Select a session from the sidebar to view its output</div>
Expand Down
59 changes: 0 additions & 59 deletions test/e2e/buffer-extension.pw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,63 +67,4 @@ extendedTest.describe('Buffer Extension on Input', () => {
expect(afterRaw).toContain('a')
}
)

extendedTest(
'should extend xterm display when sending input to interactive bash session',
async ({ page, api }) => {
const description = 'Xterm display test session'
await setupSession(page, api, description)
const initialLines = await page
.locator('[data-testid="test-output"] .output-line')
.allTextContents()
const initialContent = initialLines.join('\n')
// Initial content should have bash prompt
expect(initialContent).toContain('$')

// Create a new session with different output
await api.sessions.create({
command: 'bash',
args: ['-c', 'echo "New session test"'],
description: 'New test session',
})
await page.waitForSelector('.session-item:has-text("New test session")')
await page.locator('.session-item:has-text("New test session")').click()
await page.waitForTimeout(1000)

const afterLines = await page
.locator('[data-testid="test-output"] .output-line')
.allTextContents()
const afterContent = afterLines.join('\n')
expect(afterContent).toContain('New session test')
// Content should have changed (don't check length since initial bash prompt is long)
}
)

extendedTest('should extend xterm display when running echo command', async ({ page, api }) => {
const description = 'Echo display test session'
await setupSession(page, api, description)
const initialLines = await page
.locator('[data-testid="test-output"] .output-line')
.allTextContents()
const initialContent = initialLines.join('\n')
// Initial content should have bash prompt
expect(initialContent).toContain('$')

// Create a session that produces 'a' in output
await api.sessions.create({
command: 'bash',
args: ['-c', 'echo a'],
description: 'Echo a session',
})
await page.waitForSelector('.session-item:has-text("Echo a session")')
await page.locator('.session-item:has-text("Echo a session")').click()
await page.waitForTimeout(1000)

const afterLines = await page
.locator('[data-testid="test-output"] .output-line')
.allTextContents()
const afterContent = afterLines.join('\n')
expect(afterContent).toContain('a')
// Content should have changed (don't check length since initial bash prompt is long)
})
})
124 changes: 0 additions & 124 deletions test/e2e/e2e/pty-live-streaming.pw.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { test as extendedTest } from '../fixtures'
import { expect } from '@playwright/test'
import type { PTYSessionInfo } from '../../../src/plugin/pty/types'

extendedTest.describe('PTY Live Streaming', () => {
extendedTest('should preserve and display complete historical output buffer', async ({ api }) => {
Expand Down Expand Up @@ -61,127 +60,4 @@ extendedTest.describe('PTY Live Streaming', () => {
// TODO: Re-enable UI verification once page reload issues are resolved
// The core functionality (buffer preservation) is working correctly
})

extendedTest(
'should receive live WebSocket updates from running PTY session',
async ({ page, api }) => {
// Page automatically navigated to server URL by fixture
// Sessions automatically cleared by fixture

// Create a fresh session for this test
const initialSessions = await api.sessions.list()
if (initialSessions.length === 0) {
await api.sessions.create({
command: 'bash',
args: [
'-c',
'echo "Welcome to live streaming test"; echo "Type commands and see real-time output"; while true; do LC_TIME=C date +"%a %d. %b %H:%M:%S %Z %Y: Live update..."; sleep 0.1; done',
],
description: 'Live streaming test session',
})
// Give session a moment to start before polling
await new Promise((resolve) => setTimeout(resolve, 500))
// Wait a bit for the session to start and reload to get updated session list
// Wait until running session is available in API
const sessionStartTime = Date.now()
const sessionTimeoutMs = 10000 // Allow more time for session to start
while (Date.now() - sessionStartTime < sessionTimeoutMs) {
try {
const sessions = await api.sessions.list()
const targetSession = sessions.find(
(s: PTYSessionInfo) =>
s.description === 'Live streaming test session' && s.status === 'running'
)
if (targetSession) break
} catch (error) {
console.warn('Error checking session status:', error)
}
await new Promise((resolve) => setTimeout(resolve, 200))
}
if (Date.now() - sessionStartTime >= sessionTimeoutMs) {
throw new Error('Timeout waiting for session to become running')
}
}

// Wait for sessions to load
await page.waitForSelector('.session-item', { timeout: 5000 })

// Find the running session
const sessionCount = await page.locator('.session-item').count()
const allSessions = page.locator('.session-item')

let runningSession = null
for (let i = 0; i < sessionCount; i++) {
const session = allSessions.nth(i)
const statusBadge = await session.locator('.status-badge').textContent()
if (statusBadge === 'running') {
runningSession = session
break
}
}

if (!runningSession) {
throw new Error('No running session found')
}

await runningSession.click()

// Wait for WebSocket to stabilize
// Wait for output container or debug info to be visible
await page.waitForSelector('[data-testid="debug-info"]', { timeout: 3000 })

// Wait for initial output
await page.waitForSelector('[data-testid="test-output"] .output-line', { timeout: 3000 })

// Get initial count
const outputLines = page.locator('[data-testid="test-output"] .output-line')
const initialCount = await outputLines.count()
expect(initialCount).toBeGreaterThan(0)

// Check the debug info
const debugInfo = await page.locator('[data-testid="debug-info"]').textContent()
const debugText = (debugInfo || '') as string

// Extract WS raw_data message count
const wsMatch = debugText.match(/WS raw_data: (\d+)/)
const initialWsMessages = wsMatch && wsMatch[1] ? parseInt(wsMatch[1]) : 0

// Wait for at least 1 WebSocket streaming update
let attempts = 0
const maxAttempts = 50 // 5 seconds at 100ms intervals
let currentWsMessages = initialWsMessages
const debugElement = page.locator('[data-testid="debug-info"]')
while (attempts < maxAttempts && currentWsMessages < initialWsMessages + 1) {
await page.waitForTimeout(100)
const currentDebugText = (await debugElement.textContent()) || ''
const currentWsMatch = currentDebugText.match(/WS raw_data: (\d+)/)
currentWsMessages = currentWsMatch && currentWsMatch[1] ? parseInt(currentWsMatch[1]) : 0
if (attempts % 10 === 0) {
// Log every second
}
attempts++
}

// Check final state

// Check final output count
// Validate that live streaming is working by checking output increased

// Check that the new lines contain the expected timestamp format if output increased
// Check that new live update lines were added during WebSocket streaming
const finalOutputLines = await outputLines.count()
// Look for lines that contain "Live update..." pattern
let liveUpdateFound = false
for (let i = Math.max(0, finalOutputLines - 10); i < finalOutputLines; i++) {
const lineText = await outputLines.nth(i).textContent()
if (lineText && lineText.includes('Live update...')) {
liveUpdateFound = true

break
}
}

expect(liveUpdateFound).toBe(true)
}
)
})