Thank you for your interest in contributing to TermQ! This guide will help you get started.
- Quick Start
- Requirements
- Development Workflow
- Project Structure
- Building
- Testing
- Linting & Formatting
- Debugging
- Releasing
- CI/CD
- Makefile Reference
- Localization
- Auto-Update System (Sparkle)
- Dependencies
# Clone and build
git clone https://github.com/eyelock/termq.git
cd termq
make build
open TermQDebug.app| Requirement | For | Notes |
|---|---|---|
| macOS 14.0+ | Building & running | Required |
| Xcode Command Line Tools | Building | xcode-select --install |
| Full Xcode.app | Unit tests & linting | Download from App Store |
| SwiftLint | Linting | brew install swiftlint (requires Xcode) |
| swift-format | Formatting | brew install swift-format |
Important: Unit tests and SwiftLint require the full Xcode.app installation, not just Command Line Tools. If you only have Command Line Tools, you can still build and run the app - tests will run in CI.
- Fork the repository
- Create a feature branch from
develop:git checkout -b feature/amazing-feature develop - Make your changes
- Run checks:
make check - Commit:
git commit -m 'Add amazing feature' - Push:
git push origin feature/amazing-feature - Open a Pull Request targeting
develop
Note for maintainers: Internal development uses hyphen-separated branch names (e.g.
feat-amazing-feature) for worktree-directory compatibility. External contributors may use either convention — both work with GitHub.
termq/
├── Package.swift # Swift Package Manager manifest
├── Makefile # Build, test, lint, release commands
├── TermQ.app/ # macOS app bundle
│ └── Contents/
│ ├── Info.plist # App metadata & URL scheme
│ └── MacOS/ # Binary location
├── TermQ.entitlements # Code signing entitlements
├── Sources/
│ ├── TermQCore/ # Core library (testable models)
│ │ ├── Board.swift
│ │ ├── Column.swift
│ │ ├── Tag.swift
│ │ └── TerminalCard.swift
│ ├── TermQ/ # Main app
│ │ ├── TermQApp.swift # App entry point & URL handling
│ │ ├── ViewModels/
│ │ │ ├── BoardViewModel.swift
│ │ │ └── TerminalSessionManager.swift
│ │ └── Views/
│ │ ├── ContentView.swift
│ │ ├── KanbanBoardView.swift
│ │ ├── ColumnView.swift
│ │ ├── TerminalCardView.swift
│ │ ├── ExpandedTerminalView.swift
│ │ ├── TerminalHostView.swift
│ │ ├── CardEditorView.swift
│ │ └── ColumnEditorView.swift
│ └── termq-cli/ # CLI tool
│ └── main.swift
├── Tests/
│ └── TermQTests/ # Unit tests
└── .github/
└── workflows/
├── ci.yml # CI workflow
├── release.yml # Release workflow
└── protect-main.yml # Enforces develop/hotfix/* → main only
make build # Build debug app bundle (TermQDebug.app, signed)
make build-release # Build release binaries
make release-app # Build and sign release app bundle (TermQ.app)
make compile # Compile Swift binaries only (no app bundle)
make install # Install app to /Applications
make install-cli # Install CLI to /usr/local/binRun the debug app:
open TermQDebug.app
# Or use the convenience target
make debugmake testThe Makefile automatically sets DEVELOPER_DIR to use the full Xcode toolchain, avoiding "no such module 'XCTest'" errors that occur with just Command Line Tools.
Note: Tests require full Xcode.app installed (not just Command Line Tools). If unavailable locally, tests will still run in CI.
You will see this line at the end of make test output:
✔ Test run with 0 tests in 0 suites passed after 0.001 seconds.
This is expected and is not a failure. Xcode 16+ ships two test runners: XCTest (which runs the project's test suite) and the newer Swift Testing framework (@Test-attributed functions). TermQ uses XCTest exclusively, so the Swift Testing runner finds nothing and exits cleanly with this message. All 900+ tests ran via XCTest and their results appear earlier in the output.
# Install tools (first time only)
make install-swiftlint
make install-swift-format
# Lint
make lint # Check for issues
make lint-fix # Auto-fix issues
# Format
make format # Format all code
make format-check # Check formatting (CI mode)
# Run all checks
make checkTermQ uses a structured logging system in Sources/TermQ/Services/TermQLogger.swift.
Do not use print() or NSLog() for diagnostics — use TermQLogger instead.
TermQLogger.tmux.debug("sizeChanged pane=\(id) \(cols)x\(rows)")
TermQLogger.session.info("Connected to session=\(name) existing=\(wasExisting)")
TermQLogger.focus.warning("makeFirstResponder called on nil window")
TermQLogger.session.error("connect() threw: \(error)")| Level | When to use |
|---|---|
debug |
High-frequency events: sizeChanged, output bytes, layout passes |
info |
Noteworthy state changes: session connected, pane added, focus granted |
warning |
Unexpected but recoverable: missing pane, skipped resize |
error |
Failures that affect functionality: connect threw, process died |
| Category | Covers |
|---|---|
tmux |
Control mode protocol, resize, layout changes, pane output, commands |
pane |
Pane lifecycle: creation, layout, border updates, cleanup |
session |
Terminal session lifecycle: connect, disconnect, backend switching |
focus |
Keyboard focus: first responder, tab switching, click-to-focus |
io |
Input/output routing: key events, raw pane output bytes |
ui |
SwiftUI/AppKit view lifecycle: appear, disappear, layout passes |
Every log message goes to Apple's Unified Logging system. Stream live in a second terminal:
# All TermQ messages
log stream --predicate 'subsystem == "net.eyelock.termq"'
# Filter by category
log stream --predicate 'subsystem == "net.eyelock.termq" AND category == "tmux"'
# Show past session (last 10 minutes)
log show --predicate 'subsystem == "net.eyelock.termq"' --last 10mOr browse in Console.app — search by subsystem net.eyelock.termq.
For active debugging, set TERMQ_DEBUG=1 to also write messages to /tmp/termq-debug.log.
The file is truncated at each launch, so each run starts clean.
# Launch debug app with file logging
TERMQ_DEBUG=1 open TermQDebug.app
# Tail live output in another terminal
tail -f /tmp/termq-debug.log
# Filter to a specific category
tail -f /tmp/termq-debug.log | grep '\[tmux\]'
tail -f /tmp/termq-debug.log | grep '\[focus\]'Note: The
iocategory (raw output bytes) is verbose — only enable it when specifically debugging input/output routing, and filter aggressively withgrep.
Generate an Xcode project for full debugging support:
swift package generate-xcodeproj
open TermQ.xcodeprojThen use Xcode's debugger, breakpoints, and Instruments.
| Issue | File to Check |
|---|---|
| Terminal sessions | TerminalSessionManager.swift |
| tmux control mode | TerminalSessionManager+ControlMode.swift, TmuxControlMode.swift |
| Board persistence | BoardViewModel.swift |
| URL scheme handling | TermQApp.swift |
| Drag & drop | ColumnView.swift |
| Multi-pane layout | TmuxMultiPaneView.swift, PaneTerminalView.swift |
Test CLI integration:
open "termq://open?name=Test&path=/tmp"The project includes separate debug CLI and MCP binaries (termqclid and termqmcpd) that are built with TERMQ_DEBUG_BUILD flag and default to using:
- Debug bundle ID:
net.eyelock.termq.app.debug - Debug data directory:
~/Library/Application Support/TermQ-Debug
These binaries are automatically built and installed to TermQDebug.app/Contents/Resources/ for testing the debug app without needing --debug flags.
Usage:
# Debug CLI (works with TermQDebug.app)
./TermQDebug.app/Contents/Resources/termqclid list
./TermQDebug.app/Contents/Resources/termqclid delete <terminal-name>
# Debug MCP server
./TermQDebug.app/Contents/Resources/termqmcpd
# Regular CLI (works with production TermQ.app)
./TermQ.app/Contents/Resources/termqcli list --debugNote: The regular termqcli and termqmcp binaries target the production bundle ID by default but can use the debug data directory with the --debug flag.
The Makefile passes -Xswiftc -DTERMQ_DEBUG_BUILD when building the debug app bundle. Several behaviours differ from a release build:
| Behaviour | Release build | Debug build (TERMQ_DEBUG_BUILD) |
|---|---|---|
| Signing | Real Development/Distribution certificate (Team ID required) | Ad-hoc (codesign --sign -, no Team ID) |
| Keychain | Data Protection Keychain (kSecUseDataProtectionKeychain: true) — survives rebuilds |
Not used — see below |
| Encryption key storage | Keychain: CFBundleIdentifier + Team ID access group |
File: ~/.../TermQ-Debug/.enc-key (0o600 permissions) |
| Bundle ID | net.eyelock.termq.app |
net.eyelock.termq.app.debug |
| Data directory | ~/Library/Application Support/TermQ |
~/Library/Application Support/TermQ-Debug |
| About build number | CFBundleVersion = version string (e.g. 0.7.2) |
CFBundleVersion = <SHA>-debug (e.g. 446ee59-debug) |
| Sparkle auto-updater | Active — checks appcast and installs updates | Disabled — updater not started, "Check for Updates" menu hidden |
Why file-based key storage in debug?
The Data Protection Keychain ties items to the app's Team ID via kSecAttrAccessControl. Ad-hoc signed binaries have no Team ID, so the keychain falls back to the legacy Login Keychain and its binary-hash ACL — which invalidates on every rebuild and triggers "TermQ Debug wants to access confidential information" on every launch.
The file-based backend stores a 256-bit AES key in ~/.../TermQ-Debug/.enc-key (0o600). The secrets file itself remains AES-GCM encrypted; only the key storage location changes. Release builds are unaffected and continue to use the proper keychain backend.
How to distinguish a debug build at runtime:
# Check the About panel — the build number shows the git SHA:
# "0.7.2 (446ee59-debug)"
# Or inspect the plist directly:
plutil -p TermQDebug.app/Contents/Info.plist | grep CFBundleVersionThe project uses semantic versioning. The version is derived entirely from git tags — there is no VERSION file.
- Beta releases are tagged from
develop - Stable releases are tagged from
main, after promotingdevelop → mainvia PR
git checkout develop
git pull
git tag -a v0.8.0-beta.1 -m "Release v0.8.0-beta.1"
git push origin v0.8.0-beta.1The release workflow's verify-ci step is skipped for beta builds (CI still runs on
develop for every push and PR — only the release workflow's own verification step is
skipped). The workflow signs, notarizes, and publishes the GitHub release as a pre-release.
The appcast-beta.xml feed is updated automatically.
- Open a PR:
develop → main - Wait for CI to pass and merge
- Tag the merge commit:
git checkout main
git pull
make release # or: make release-patch / release-minor / release-majorThe release workflow verifies CI passed on that commit before building.
Before tagging (beta or stable):
make check
./scripts/localization/validate-strings.sh
make testFor a beta from develop:
git checkout develop && git pull
git tag -a v0.8.0-beta.1 -m "Release v0.8.0-beta.1"
git push origin v0.8.0-beta.1For a stable from main (after merging the develop → main PR):
git checkout main && git pull
git tag -a v0.8.0 -m "Release v0.8.0"
git push origin v0.8.0Pushing the tag triggers release.yml. Do not push the branch separately — the workflow
is tag-driven, not branch-driven.
The same Makefile targets run locally and in CI. This ensures:
- What passes locally will pass in CI
- No surprises from environment differences
- Reduced CI debugging cycles
Always run make check before pushing.
CI only runs when code-relevant files change:
Sources/**,Tests/**,Package.swift,Makefile.swiftlint.yml,.swift-format,.github/workflows/**
Documentation-only changes (README, CONTRIBUTING, etc.) won't trigger CI, reducing energy usage.
The CI workflow (.github/workflows/ci.yml) runs on push to main, develop,
and hotfix/* branches, and on pull requests targeting main or develop.
Checks run:
make build- Build verificationmake test- Unit testsmake lint- SwiftLint (with GitHub annotations in CI)make format-check- Format checkmake build-release- Release build verification
The release workflow (.github/workflows/release.yml) triggers on version tags (v*):
- Builds release binaries
- Creates signed app bundle
- Generates checksums
- Publishes GitHub Release with:
TermQ-{version}.dmg- Installer disk imageTermQ-{version}.zip- App bundle (CLI tool bundled inside)checksums.txt- SHA-256 hashes
Run make help for all available targets:
| Target | Description |
|---|---|
build |
Build signed debug app bundle (TermQDebug.app) |
compile |
Compile Swift binaries only (no app bundle) |
build-release |
Build release binaries |
release-app |
Build signed release app bundle (TermQ.app) |
clean |
Clean build artifacts |
test |
Run tests (requires Xcode) |
lint |
Run SwiftLint |
lint-fix |
Run SwiftLint with auto-fix |
debug |
Build and launch debug app |
run |
Build and launch release app |
format |
Format code with swift-format |
format-check |
Check formatting (CI mode) |
check |
Run all checks |
app |
Build debug app bundle |
sign |
Build and sign debug app |
release-app |
Build and sign release app |
install |
Install CLI to /usr/local/bin |
uninstall |
Remove CLI |
dmg |
Create distributable DMG |
zip |
Create distributable zip |
version |
Show current version |
release |
Interactive release |
release-major |
Release major version |
release-minor |
Release minor version |
release-patch |
Release patch version |
TermQ supports 40 languages. All user-facing strings should be localized.
- Add the key to
Sources/TermQ/Utilities/Strings.swift:
enum Settings {
static let newOption = String(localized: "settings.new.option")
}- Add the English translation to
Sources/TermQ/Resources/en.lproj/Localizable.strings:
"settings.new.option" = "New Option";
- Add to all other language files (or run the template script):
./scripts/localization/generate-translations.sh- Validate all languages have the key:
./scripts/localization/validate-strings.shFor LLM-assisted translation:
# Extract strings to JSON
./scripts/localization/extract-to-json.sh > strings.json
# Have Claude translate the JSON, then update the .strings files| File | Purpose |
|---|---|
Sources/TermQ/Utilities/Strings.swift |
Centralized string key definitions |
Sources/TermQ/Utilities/SupportedLanguage.swift |
Language picker model |
Sources/TermQ/Resources/en.lproj/Localizable.strings |
English (base) translations |
Sources/TermQ/Resources/<lang>.lproj/Localizable.strings |
Other language translations |
scripts/localization/*.sh |
Translation management scripts |
.claude/commands/localization.md |
Claude command for localization tasks |
Keys follow the pattern: domain.description.qualifier
board.column.options- Board domain, column optionseditor.field.name- Editor domain, name fieldsettings.section.language- Settings domain, language section
The app supports all macOS languages including: English, Spanish, French, German, Italian, Portuguese, Dutch, Swedish, Danish, Finnish, Norwegian, Polish, Russian, Ukrainian, Czech, Slovak, Hungarian, Romanian, Croatian, Slovenian, Greek, Turkish, Hebrew, Arabic, Thai, Vietnamese, Indonesian, Malay, Chinese (Simplified, Traditional, Hong Kong), Japanese, Korean, Hindi, and Catalan.
TermQ uses Sparkle 2.x for automatic updates.
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ TermQ App │────▶│ appcast.xml │────▶│ GitHub Releases │
│ (Sparkle 2.x) │ │ (GitHub Pages) │ │ (DMG/ZIP) │
└─────────────────┘ └──────────────────┘ └─────────────────┘
- App checks
appcast.xmlhosted on GitHub Pages for new versions - Appcast points to GitHub Release artifacts (DMG/ZIP)
- Sparkle downloads, verifies EdDSA signature, installs, and relaunches
| File | Purpose |
|---|---|
Sources/TermQ/TermQApp.swift |
SparkleUpdaterDelegate and UpdaterViewModel |
Sources/TermQ/Views/SettingsView.swift |
Update settings UI |
Info.plist.template |
Sparkle configuration keys |
scripts/generate-appcast.sh |
Generates appcast from GitHub Releases |
.github/workflows/update-appcast.yml |
Auto-updates appcast on release |
Docs/appcast.xml |
Stable channel appcast |
Docs/appcast-beta.xml |
Beta channel appcast (includes pre-releases) |
Sparkle's SUStandardVersionComparator truncates version strings at the first dash character. This means 0.7.0-beta.8 and 0.7.0-beta.9 both reduce to 0.7.0 and compare as equal — no update is ever offered between consecutive betas.
To avoid this, the app uses dot-notation for all version fields. The git tag uses dashes (required for GitHub to detect pre-releases); a conversion is applied at build time:
| Git tag | App version (CFBundleVersion, sparkle:version) |
|---|---|
v0.7.0-beta.9 |
0.7.0.b9 |
v0.7.0-alpha.3 |
0.7.0.a3 |
v0.7.0-rc.2 |
0.7.0.rc2 |
v0.7.0 |
0.7.0 |
Both CFBundleVersion and CFBundleShortVersionString use the dot-notation format. The git SHA is stored separately in the custom plist key TermQBuildSHA and displayed in Settings → About.
The appcast is automatically regenerated when a new release is published:
# Manual generation (usually not needed)
./scripts/generate-appcast.shThis script:
- Fetches releases from GitHub API
- Extracts version, download URL, file size, and release notes
- Converts dash-notation tags to dot-notation (
sparkle_version()function) - Generates
Docs/appcast.xml(stable) andDocs/appcast-beta.xml(includes prereleases)
Sparkle 2.x uses EdDSA signatures for update verification:
-
Generate key pair (one-time setup):
# From Sparkle tools ./bin/generate_keys -
Store private key as GitHub Secret:
SPARKLE_PRIVATE_KEY -
Add public key to
Info.plist.template:<key>SUPublicEDKey</key> <string>[your-public-key]</string>
| Channel | Appcast | Description |
|---|---|---|
| Stable | appcast.xml |
Production releases only |
| Beta | appcast-beta.xml |
Includes pre-releases (alpha, beta, rc) |
Users select their channel in Settings > Updates > "Include beta releases".
-
Run a local HTTP server:
cd Docs python3 -m http.server 8080 -
Temporarily modify
SparkleUpdaterDelegate.feedURLString(for:)to usehttp://localhost:8080/appcast.xml -
Build and run the app to test update detection
Key Sparkle settings in Info.plist.template:
<key>SUFeedURL</key>
<string>https://eyelock.github.io/TermQ/appcast.xml</string>
<key>SUPublicEDKey</key>
<string>[EdDSA public key]</string>
<key>SUEnableAutomaticChecks</key>
<true/>
<key>SUAllowsAutomaticUpdates</key>
<true/>
<key>SUScheduledCheckInterval</key>
<integer>86400</integer> <!-- 24 hours -->- Build errors with Sparkle: Ensure Xcode is installed (not just Command Line Tools)
- Updates not detected: Check appcast URL accessibility and XML validity. Also verify that
CFBundleVersionin the installed app is dot-notation (e.g.0.7.0.b9), not a git SHA — runplutil -p TermQ.app/Contents/Info.plist | grep CFBundleVersion - Signature verification failed: Ensure
SUPublicEDKeymatches the private key used for signing - All betas compare equal: Confirms dash notation is still in use. Check that
sparkle:versionin the appcast andCFBundleVersionin the app both use dot-notation (0.7.0.b9, not0.7.0-beta.9)
- SwiftTerm - Terminal emulation
- swift-argument-parser - CLI argument parsing
- Sparkle - Automatic updates framework
Understanding how TermQ operations affect terminal sessions is critical for development. The table below documents the complete behavior matrix for all operations.
| Operation | Direct Session | TMUX Session | Card State | Notes |
|---|---|---|---|---|
| Open Terminal | Creates new shell process | Creates/attaches to tmux session | Active | Terminal opens in tab |
| Close Tab | Terminates shell (sends "exit\n") | Detaches (sends Ctrl+B d) | Active | Session preserved for TMUX |
| Delete Card (soft delete) | Terminates shell (sends "exit\n") | Detaches (sends Ctrl+B d) | deletedAt set |
Card moved to bin, TMUX session becomes orphaned |
| Delete Tab Card (close+delete) | Terminates shell | Detaches | deletedAt set |
Same as Delete Card |
| Permanently Delete Card (from bin) | No action | No action | Card removed | Session remains orphaned if still running |
| Restore Card (from bin) | - | - | deletedAt cleared |
Restores card metadata only |
| Kill Session | SIGKILL process | Runs tmux kill-session |
Active | Forcefully terminates everything |
| Kill Terminal (for stuck sessions) | SIGKILL process | Runs tmux kill-session |
Active | Same as Kill Session |
| Restart Session | Marks for restart, recreates | Marks for restart, recreates | Active | Fresh session created |
| Close Unfavourited Tabs | Terminates shell | Detaches | Active | Batch close operation |
| Recover Orphaned Session | N/A | Reattaches to existing session | Creates new card | Only for TMUX |
| Dismiss Recoverable Session | N/A | No action | - | Hides from recovery list |
| Kill Recoverable Session | N/A | Runs tmux kill-session |
- | Terminates orphaned session |
- TMUX sessions persist independently - Closing tabs or deleting cards detaches but preserves the session
- Direct sessions are ephemeral - They terminate when tabs close or cards are deleted
- Soft delete creates orphaned sessions - Deleted cards with TMUX sessions show up in the recovery dialog
- Kill operations are destructive - Use only for stuck/unresponsive terminals or intentional cleanup
- Recovery is TMUX-only - Direct sessions cannot be recovered once terminated
See Sources/TermQ/ViewModels/TerminalSessionManager.swift:482-507 for the core session removal logic:
func removeSession(for cardId: UUID, killTmuxSession: Bool = false) {
// ...
switch session.backend {
case .direct:
// Direct mode: terminate the shell
session.terminal.send(txt: "exit\n")
case .tmux:
if killTmuxSession {
// Explicitly kill the tmux session
try? await tmuxManager.killSession(name: sessionName)
} else {
// Just detach - session keeps running
session.terminal.send(txt: "\u{02}d") // Ctrl+B, d
}
}
}