Compare commits

3 Commits

Author SHA1 Message Date
9f6e607e78 Update readme 2026-03-14 02:58:59 +11:00
3d68f08e1d Add mascot demo. Update assets for readme 2026-03-14 02:45:15 +11:00
cf3dba8fe4 File system cleanup 2026-03-13 21:26:06 +11:00
88 changed files with 797 additions and 20 deletions

BIN
.github/assets/CommandNotch-Demo.mp4 vendored Normal file

Binary file not shown.

BIN
.github/assets/CommandNotch-Open.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 846 KiB

BIN
.github/assets/CommandNotch-Settings.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 575 KiB

BIN
.github/assets/bch-receiving-address.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

422
.github/demo/aurora.py vendored Executable file
View File

@@ -0,0 +1,422 @@
#!/usr/bin/env python3
"""Looping bathtub-and-duck terminal animation for Downterm demos."""
from __future__ import annotations
import argparse
import atexit
import math
import shutil
import signal
import sys
import time
from typing import Sequence
CSI = "\x1b["
RESET = f"{CSI}0m"
HIDE_CURSOR = f"{CSI}?25l"
SHOW_CURSOR = f"{CSI}?25h"
ALT_SCREEN_ON = f"{CSI}?1049h"
ALT_SCREEN_OFF = f"{CSI}?1049l"
CLEAR = f"{CSI}2J"
HOME = f"{CSI}H"
TUB = "tub"
TUB_DARK = "tub_dark"
WATER = "water"
WATER_DEEP = "water_deep"
FOAM = "foam"
BUBBLE = "bubble"
DUCK = "duck"
DUCK_LIGHT = "duck_light"
BEAK = "beak"
EYE = "eye"
LABEL = "label"
LABEL_SHADOW = "label_shadow"
RGB = {
TUB: (171, 177, 191),
TUB_DARK: (112, 118, 133),
WATER: (71, 185, 214),
WATER_DEEP: (36, 121, 161),
FOAM: (196, 242, 250),
BUBBLE: (184, 233, 245),
DUCK: (255, 208, 64),
DUCK_LIGHT: (255, 232, 132),
BEAK: (255, 154, 58),
EYE: (251, 251, 253),
LABEL: (207, 212, 222),
LABEL_SHADOW: (74, 79, 92),
}
WATER_SHADE = " .:-=+*#"
def parse_args(argv: Sequence[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Render a looping bathtub-and-duck animation."
)
parser.add_argument(
"--fps",
type=float,
default=18.0,
help="Target frames per second. Default: 18",
)
parser.add_argument(
"--speed",
type=float,
default=1.0,
help="Animation speed multiplier. Default: 1.0",
)
parser.add_argument(
"--duration",
type=float,
default=0.0,
help="Optional run duration in seconds. Default: infinite",
)
parser.add_argument(
"--label",
default="",
help="Optional centered label, e.g. --label Downterm",
)
parser.add_argument(
"--mono",
action="store_true",
help="Disable color and render as monochrome ASCII.",
)
return parser.parse_args(argv)
def terminal_size() -> tuple[int, int]:
size = shutil.get_terminal_size((90, 28))
return max(40, size.columns), max(12, size.lines)
def clamp(value: float, low: float, high: float) -> float:
return max(low, min(high, value))
def scene_size(width: int, height: int) -> tuple[int, int]:
visible_height = max(8, height - 1)
scene_width = min(max(48, width - 2), width, 110)
scene_height = min(max(16, visible_height - 1), visible_height, 24)
return scene_width, scene_height
def make_canvas(width: int, height: int) -> list[list[tuple[str, str | None]]]:
return [[(" ", None) for _ in range(width)] for _ in range(height)]
def put(canvas: list[list[tuple[str, str | None]]], x: int, y: int, char: str, color: str | None) -> None:
if 0 <= y < len(canvas) and 0 <= x < len(canvas[y]) and char:
canvas[y][x] = (char[0], color)
def draw_text(
canvas: list[list[tuple[str, str | None]]],
x: int,
y: int,
text: str,
color: str | None,
) -> None:
for index, char in enumerate(text):
if char != " ":
put(canvas, x + index, y, char, color)
def draw_sprite_text(
canvas: list[list[tuple[str, str | None]]],
x: int,
y: int,
text: str,
color: str | None,
) -> None:
for index, char in enumerate(text):
if char == " ":
continue
if char == "~":
put(canvas, x + index, y, " ", None)
continue
put(canvas, x + index, y, char, color)
def water_char(intensity: float) -> str:
index = int(clamp(intensity, 0.0, 0.9999) * len(WATER_SHADE))
return WATER_SHADE[index]
def tub_geometry(scene_width: int, scene_height: int) -> dict[str, int]:
top_y = 1
notch_width = max(28, min(scene_width - 18, int(scene_width * 0.44)))
rim_left = max(8, (scene_width - notch_width) // 2)
rim_right = min(scene_width - 9, rim_left + notch_width)
wall_left = rim_left + 1
wall_right = rim_right - 1
body_bottom = min(scene_height - 10, top_y + max(6, scene_height // 3))
water_top = top_y + 2
return {
"rim_left": rim_left,
"rim_right": rim_right,
"top_y": top_y,
"wall_left": wall_left,
"wall_right": wall_right,
"body_bottom": body_bottom,
"water_top": water_top,
}
def draw_tub(canvas: list[list[tuple[str, str | None]]], geometry: dict[str, int]) -> None:
rim_left = geometry["rim_left"]
rim_right = geometry["rim_right"]
top_y = geometry["top_y"]
wall_left = geometry["wall_left"]
wall_right = geometry["wall_right"]
body_bottom = geometry["body_bottom"]
draw_text(canvas, 2, top_y, "_" * max(0, rim_left - 2), TUB)
put(canvas, rim_left, top_y, "_", TUB)
draw_text(canvas, rim_right + 1, top_y, "_" * max(0, len(canvas[0]) - rim_right - 3), TUB)
put(canvas, rim_right, top_y, "_", TUB)
for y in range(top_y + 1, body_bottom):
put(canvas, wall_left, y, "|", TUB)
put(canvas, wall_right, y, "|", TUB)
put(canvas, wall_left, body_bottom, "\\", TUB_DARK)
draw_text(canvas, wall_left + 1, body_bottom, "_" * max(0, wall_right - wall_left - 1), TUB_DARK)
put(canvas, wall_right, body_bottom, "/", TUB_DARK)
def draw_water_fill(canvas: list[list[tuple[str, str | None]]], geometry: dict[str, int], t: float) -> None:
rim_left = geometry["wall_left"] + 1
rim_right = geometry["wall_right"] - 1
water_top = geometry["water_top"]
body_bottom = geometry["body_bottom"] - 1
depth = max(1, body_bottom - water_top)
for row in range(depth + 1):
y = water_top + row
for x in range(rim_left, rim_right + 1):
wave = math.sin((x * 0.23) + (t * 2.1) + row * 0.55)
shimmer = math.sin((x * 0.07) - (t * 1.35))
if row == 0:
char = "~" if wave > -0.22 else "-"
color = FOAM if shimmer > 0.25 else WATER
else:
density = 0.40 + row / max(1, depth)
value = 0.45 + (wave * 0.25) + (shimmer * 0.10) + density * 0.30
char = water_char(value)
color = WATER if row <= 2 else WATER_DEEP
put(canvas, x, y, char, color)
def draw_water_surface(canvas: list[list[tuple[str, str | None]]], geometry: dict[str, int], t: float) -> None:
left = geometry["wall_left"] + 1
right = geometry["wall_right"] - 1
y = geometry["water_top"]
for x in range(left, right + 1):
wave = math.sin((x * 0.23) + (t * 2.1))
shimmer = math.sin((x * 0.11) - (t * 1.15))
char = "~" if wave > -0.18 else "-"
color = FOAM if shimmer > 0.35 else WATER
put(canvas, x, y, char, color)
def draw_bubbles(canvas: list[list[tuple[str, str | None]]], geometry: dict[str, int], t: float) -> None:
rim_left = geometry["wall_left"]
water_top = geometry["water_top"]
seeds = (
(0.00, rim_left + 11, 0.9),
(0.29, rim_left + 19, 1.1),
(0.57, rim_left + 27, 0.8),
(0.82, rim_left + 35, 1.0),
)
for offset, base_x, speed in seeds:
progress = (t * 0.11 * speed + offset) % 1.0
y = water_top + 4 - int(progress * 9)
x = base_x + int(round(math.sin((t * 0.8 * speed) + offset * 8.0) * 2))
put(canvas, x, y, "o" if progress < 0.5 else ".", BUBBLE)
def draw_duck(canvas: list[list[tuple[str, str | None]]], geometry: dict[str, int], t: float) -> None:
rim_left = geometry["wall_left"]
rim_right = geometry["wall_right"]
water_top = geometry["water_top"]
span = rim_right - rim_left
bob = math.sin(t * 1.35) * 0.55
drift = math.sin(t * 0.55) * 2.0
blink_phase = int(t * 2.1) % 12
eye_char = "-" if blink_phase == 7 else "."
x = int(rim_left + span * 0.34 + drift)
y = int(water_top - 1 + bob)
rows = [
" __",
f" ___(~{eye_char})>",
" \\~<_.~) ",
" `---' ",
]
colors = [DUCK_LIGHT, DUCK, DUCK, DUCK]
for index, row in enumerate(rows):
draw_sprite_text(canvas, x, y + index, row, colors[index])
put(canvas, x + 8, y + 1, ">", BEAK)
put(canvas, x + 6, y + 1, eye_char, EYE)
if 0.74 < (t * 0.18) % 1.0 < 0.90:
splash_x = x + 9
splash_y = geometry["water_top"] - 1
for dx, dy, char in ((0, 0, "."), (2, -1, "o"), (4, 0, "."), (5, -2, "o")):
put(canvas, splash_x + dx, splash_y + dy, char, BUBBLE)
def logo_rows(text: str) -> list[str]:
if text == "commandNotch":
return [
" ██████╗ ██████╗ ███╗ ███╗███╗ ███╗ █████╗ ███╗ ██╗██████╗ ",
" ██╔════╝██╔═══██╗████╗ ████║████╗ ████║██╔══██╗████╗ ██║██╔══██╗",
" ██║ ██║ ██║██╔████╔██║██╔████╔██║███████║██╔██╗ ██║██║ ██║",
" ██║ ██║ ██║██║╚██╔╝██║██║╚██╔╝██║██╔══██║██║╚██╗██║██║ ██║",
" ╚██████╗╚██████╔╝██║ ╚═╝ ██║██║ ╚═╝ ██║██║ ██║██║ ╚████║██████╔╝",
" ╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═══╝╚═════╝ ",
"",
" ███╗ ██╗ ██████╗ ████████╗ ██████╗██╗ ██╗",
" ████╗ ██║██╔═══██╗╚══██╔══╝██╔════╝██║ ██║",
" ██╔██╗ ██║██║ ██║ ██║ ██║ ███████║",
" ██║╚██╗██║██║ ██║ ██║ ██║ ██╔══██║",
" ██║ ╚████║╚██████╔╝ ██║ ╚██████╗██║ ██║",
" ╚═╝ ╚═══╝ ╚═════╝ ╚═╝ ╚═════╝╚═╝ ╚═╝",
]
return [text]
def draw_label(canvas: list[list[tuple[str, str | None]]], label: str) -> None:
logo = logo_rows(label or "commandNotch")
start_y = len(canvas) - len(logo) - 1
for index, row in enumerate(logo):
start_x = max(0, (len(canvas[0]) - len(row)) // 2)
draw_text(canvas, start_x, start_y + index, row, LABEL)
def scene_canvas(scene_width: int, scene_height: int, t: float, label: str) -> list[list[tuple[str, str | None]]]:
canvas = make_canvas(scene_width, scene_height)
geometry = tub_geometry(scene_width, scene_height)
draw_tub(canvas, geometry)
draw_water_fill(canvas, geometry, t)
draw_water_surface(canvas, geometry, t)
draw_duck(canvas, geometry, t)
draw_bubbles(canvas, geometry, t)
draw_label(canvas, label)
return canvas
def ansi_rgb(color_name: str | None) -> str:
if color_name is None:
return RESET
red, green, blue = RGB[color_name]
return f"{CSI}38;2;{red};{green};{blue}m"
def render_scene_line(cells: list[tuple[str, str | None]], mono: bool) -> str:
if mono:
return "".join(char for char, _ in cells)
chunks: list[str] = []
current_color: str | None = None
for char, color in cells:
if color != current_color:
chunks.append(ansi_rgb(color))
current_color = color
chunks.append(char)
chunks.append(RESET)
return "".join(chunks)
def render_frame(width: int, height: int, t: float, label: str, mono: bool) -> str:
visible_height = max(8, height - 1)
scene_width, scene_height = scene_size(width, height)
scene = scene_canvas(scene_width, scene_height, t, label)
top_padding = max(0, (visible_height - scene_height) // 2)
left_padding = max(0, (width - scene_width) // 2)
pieces: list[str] = [HOME]
blank_line = " " * width
for row in range(visible_height):
if top_padding <= row < top_padding + scene_height:
scene_row = scene[row - top_padding]
rendered = render_scene_line(scene_row, mono)
right_padding = max(0, width - left_padding - scene_width)
line = (" " * left_padding) + rendered + (" " * right_padding)
else:
line = blank_line
pieces.append(line)
if row != visible_height - 1:
pieces.append("\n")
return "".join(pieces)
def restore_terminal() -> None:
sys.stdout.write(f"{RESET}{SHOW_CURSOR}{ALT_SCREEN_OFF}")
sys.stdout.flush()
def install_cleanup() -> None:
def handle_signal(signum, _frame) -> None:
restore_terminal()
raise SystemExit(128 + signum)
atexit.register(restore_terminal)
signal.signal(signal.SIGINT, handle_signal)
signal.signal(signal.SIGTERM, handle_signal)
def main(argv: Sequence[str]) -> int:
args = parse_args(argv)
if args.fps <= 0:
raise SystemExit("--fps must be greater than 0")
if args.speed <= 0:
raise SystemExit("--speed must be greater than 0")
install_cleanup()
sys.stdout.write(f"{ALT_SCREEN_ON}{HIDE_CURSOR}{CLEAR}")
sys.stdout.flush()
frame_interval = 1.0 / args.fps
start = time.perf_counter()
frame_index = 0
while True:
now = time.perf_counter()
elapsed = now - start
if args.duration > 0 and elapsed >= args.duration:
break
width, height = terminal_size()
t = elapsed * args.speed
sys.stdout.write(render_frame(width, height, t, args.label, args.mono))
sys.stdout.flush()
frame_index += 1
target = start + (frame_index * frame_interval)
sleep_for = target - time.perf_counter()
if sleep_for > 0:
time.sleep(sleep_for)
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))

4
.gitignore vendored
View File

@@ -79,7 +79,5 @@ jspm_packages/
dist/
build/
**/Release*
CommandNotch 20*
# Mac... files
**/.DS_Store

89
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,89 @@
# Contributing
Thanks for contributing to CommandNotch.
## Before You Start
- Use macOS 14+.
- Install Xcode 16+.
- Install `xcodegen` with Homebrew.
## Local Setup
```bash
cd CommandNotch
xcodegen generate --spec project.yml
open CommandNotch.xcodeproj
```
## Useful Commands
Generate the project:
```bash
cd CommandNotch
xcodegen generate --spec project.yml
```
Build:
```bash
cd CommandNotch
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer \
xcodebuild build -project CommandNotch.xcodeproj -scheme CommandNotch -destination 'platform=macOS'
```
Run tests:
```bash
cd CommandNotch
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer \
xcodebuild test -project CommandNotch.xcodeproj -scheme CommandNotch -destination 'platform=macOS'
```
## What Helps Most
- Bug fixes with a clear reproduction path.
- UI polish that keeps the app feeling intentional instead of generic.
- Accessibility improvements.
- Tests around workspace, screen, settings, and hotkey behavior.
- Docs and onboarding improvements.
## Code Guidelines
- Keep changes targeted.
- Preserve the existing SwiftUI + AppKit split.
- Prefer typed settings and explicit state ownership over hidden side effects.
- Add or update tests when you change behavior.
- Regenerate the Xcode project with XcodeGen if you add or remove files.
## Pull Requests
- Keep PRs small enough to review comfortably.
- Explain the user-facing impact.
- Note any follow-up work or tradeoffs.
- Include screenshots for visible UI changes when possible.
- Mention the exact build/test command you used.
## Issues
When filing a bug, include:
- macOS version
- what you expected
- what actually happened
- reproduction steps
- screenshots or recordings if they help
## Media Updates
README screenshots live in `.github/assets/`.
If you update the UI significantly, refresh:
- `.github/assets/CommandNotch-Open.png`
- `.github/assets/CommandNotch-Settings.png`
## License
By contributing, you agree that your contributions will be released under the MIT License in this repository.

View File

@@ -12,14 +12,23 @@ class TerminalSession: NSObject, ObservableObject, LocalProcessDelegate, @precon
private var keyEventMonitor: Any?
private let backgroundColor = NSColor.black
private let configuredShellPath: String
private let launchDirectory: String
@Published var title: String = "shell"
@Published var isRunning: Bool = true
@Published var currentDirectory: String?
init(fontSize: CGFloat, theme: TerminalTheme, shellPath: String) {
init(
fontSize: CGFloat,
theme: TerminalTheme,
shellPath: String,
initialDirectory: String? = nil,
startImmediately: Bool = true
) {
terminalView = TerminalView(frame: NSRect(x: 0, y: 0, width: 600, height: 300))
configuredShellPath = shellPath
launchDirectory = Self.resolveInitialDirectory(initialDirectory)
currentDirectory = launchDirectory
super.init()
terminalView.terminalDelegate = self
@@ -29,7 +38,11 @@ class TerminalSession: NSObject, ObservableObject, LocalProcessDelegate, @precon
applyTheme(theme)
installCommandArrowMonitor()
if startImmediately {
startShell()
} else {
isRunning = false
}
}
deinit {
@@ -52,12 +65,29 @@ class TerminalSession: NSObject, ObservableObject, LocalProcessDelegate, @precon
args: ["-l"],
environment: nil,
execName: loginExecName,
currentDirectory: NSHomeDirectory()
currentDirectory: launchDirectory
)
process = proc
title = shellName
}
private static func resolveInitialDirectory(_ directory: String?) -> String {
normalizedDirectory(directory) ?? NSHomeDirectory()
}
private static func normalizedDirectory(_ directory: String?) -> String? {
let trimmed = directory?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !trimmed.isEmpty else {
return nil
}
if let url = URL(string: trimmed), url.isFileURL {
return url.path(percentEncoded: false)
}
return (trimmed as NSString).expandingTildeInPath
}
private func resolveShell() -> String {
let custom = configuredShellPath.trimmingCharacters(in: .whitespacesAndNewlines)
if !custom.isEmpty && FileManager.default.isExecutableFile(atPath: custom) {
@@ -145,7 +175,8 @@ class TerminalSession: NSObject, ObservableObject, LocalProcessDelegate, @precon
}
func hostCurrentDirectoryUpdate(source: TerminalView, directory: String?) {
currentDirectory = directory
guard let normalizedDirectory = Self.normalizedDirectory(directory) else { return }
currentDirectory = normalizedDirectory
}
func scrolled(source: TerminalView, position: Double) {}

View File

@@ -3,12 +3,27 @@ import Combine
@MainActor
protocol TerminalSessionFactoryType {
func makeSession(fontSize: CGFloat, theme: TerminalTheme, shellPath: String) -> TerminalSession
func makeSession(
fontSize: CGFloat,
theme: TerminalTheme,
shellPath: String,
initialDirectory: String?
) -> TerminalSession
}
struct LiveTerminalSessionFactory: TerminalSessionFactoryType {
func makeSession(fontSize: CGFloat, theme: TerminalTheme, shellPath: String) -> TerminalSession {
TerminalSession(fontSize: fontSize, theme: theme, shellPath: shellPath)
func makeSession(
fontSize: CGFloat,
theme: TerminalTheme,
shellPath: String,
initialDirectory: String?
) -> TerminalSession {
TerminalSession(
fontSize: fontSize,
theme: theme,
shellPath: shellPath,
initialDirectory: initialDirectory
)
}
}
@@ -90,7 +105,8 @@ final class WorkspaceController: ObservableObject {
let session = sessionFactory.makeSession(
fontSize: config.fontSize,
theme: config.theme,
shellPath: config.shellPath
shellPath: config.shellPath,
initialDirectory: activeTab?.currentDirectory
)
titleObservers[session.id] = session.$title

View File

@@ -313,7 +313,12 @@ private final class ScreenRegistryTestSettingsProvider: TerminalSessionConfigura
private struct ScreenRegistryUnusedTerminalSessionFactory: TerminalSessionFactoryType {
@MainActor
func makeSession(fontSize: CGFloat, theme: TerminalTheme, shellPath: String) -> TerminalSession {
func makeSession(
fontSize: CGFloat,
theme: TerminalTheme,
shellPath: String,
initialDirectory: String?
) -> TerminalSession {
fatalError("ScreenRegistryTests should not create live terminal sessions.")
}
}

View File

@@ -120,6 +120,64 @@ final class WorkspaceRegistryTests: XCTestCase {
}
}
@MainActor
final class WorkspaceControllerTests: XCTestCase {
func testNewTabUsesActiveTabCurrentDirectory() {
let factory = RecordingTerminalSessionFactory()
let controller = WorkspaceController(
summary: WorkspaceSummary(name: "Main"),
sessionFactory: factory,
settingsProvider: TestSettingsProvider(),
bootstrapDefaultTab: false
)
controller.newTab()
controller.activeTab?.currentDirectory = "/tmp/Raycast"
controller.newTab()
XCTAssertEqual(factory.requestedDirectories, [nil, "/tmp/Raycast"])
XCTAssertEqual(controller.activeTab?.currentDirectory, "/tmp/Raycast")
XCTAssertEqual(controller.tabs.count, 2)
XCTAssertEqual(controller.activeTabIndex, 1)
}
func testNewTabNormalizesCurrentDirectoryFileURL() {
let factory = RecordingTerminalSessionFactory()
let controller = WorkspaceController(
summary: WorkspaceSummary(name: "Main"),
sessionFactory: factory,
settingsProvider: TestSettingsProvider(),
bootstrapDefaultTab: false
)
let expectedPath = "/tmp/Raycast Folder"
controller.newTab()
controller.activeTab?.currentDirectory = URL(fileURLWithPath: expectedPath).absoluteString
controller.newTab()
XCTAssertEqual(controller.activeTab?.currentDirectory, expectedPath)
}
func testNewTabFallsBackToHomeDirectoryWhenWorkspaceHasNoTabs() {
let factory = RecordingTerminalSessionFactory()
let controller = WorkspaceController(
summary: WorkspaceSummary(name: "Main"),
sessionFactory: factory,
settingsProvider: TestSettingsProvider(),
bootstrapDefaultTab: false
)
controller.newTab()
XCTAssertEqual(factory.requestedDirectories, [nil])
XCTAssertEqual(controller.activeTab?.currentDirectory, NSHomeDirectory())
XCTAssertEqual(controller.tabs.count, 1)
XCTAssertEqual(controller.activeTabIndex, 0)
}
}
private final class InMemoryWorkspaceStore: WorkspaceStoreType {
var savedSummaries: [WorkspaceSummary] = []
@@ -138,9 +196,35 @@ private final class TestSettingsProvider: TerminalSessionConfigurationProviding
let terminalSizePresets = TerminalSizePresetStore.loadDefaults()
}
private final class RecordingTerminalSessionFactory: TerminalSessionFactoryType {
private(set) var requestedDirectories: [String?] = []
@MainActor
func makeSession(
fontSize: CGFloat,
theme: TerminalTheme,
shellPath: String,
initialDirectory: String?
) -> TerminalSession {
requestedDirectories.append(initialDirectory)
return TerminalSession(
fontSize: fontSize,
theme: theme,
shellPath: shellPath,
initialDirectory: initialDirectory,
startImmediately: false
)
}
}
private struct UnusedTerminalSessionFactory: TerminalSessionFactoryType {
@MainActor
func makeSession(fontSize: CGFloat, theme: TerminalTheme, shellPath: String) -> TerminalSession {
func makeSession(
fontSize: CGFloat,
theme: TerminalTheme,
shellPath: String,
initialDirectory: String?
) -> TerminalSession {
fatalError("WorkspaceRegistryTests should not create live terminal sessions.")
}
}

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Harvey Zuccon
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

111
README.md Normal file
View File

@@ -0,0 +1,111 @@
# CommandNotch
<p align="center">
<img src="icons/Downterm-icon-256.png" width="128" alt="CommandNotch icon">
</p>
<p align="center">
A drop-down terminal for macOS that lives in the notch area.
</p>
<p align="center">
<img src="https://img.shields.io/badge/platform-macOS%2014%2B-black" alt="macOS 14+">
<img src="https://img.shields.io/badge/UI-SwiftUI%20%2B%20AppKit-black" alt="SwiftUI and AppKit">
<img src="https://img.shields.io/badge/license-MIT-black" alt="MIT License">
</p>
CommandNotch is a notch-native terminal overlay for macOS. It gives you a fast shell without switching spaces, keeping a full Terminal window open, or breaking your flow. Open it with a hotkey, drop into a shell, then get out of the way just as quickly.
The current Xcode target and bundle name are still `CommandNotch`, but the project is being presented publicly as **CommandNotch**.
## Why CommandNotch
- You want a terminal that is always one shortcut away.
- You like the idea of a terminal living in the menu bar / notch area instead of a full window.
- You work across multiple displays and want each screen to keep its own notch state.
- You want lightweight workspaces, detachable tabs, and shell access without giving up native macOS feel.
## Features
- Native macOS notch overlay with open and closed states.
- Fast shell sessions powered by SwiftTerm and a local login shell.
- Multiple tabs with hotkeys for new, close, next, previous, and direct tab switching.
- Workspace support, including shared workspaces across screens.
- Multi-display awareness with per-screen assignment and presentation state.
- Detachable tabs that can pop out into standalone terminal windows.
- Terminal themes: Classic, Xterm, Solarized Dark, Dracula, and Nord.
- Configurable terminal size presets with optional hotkeys.
- Hover-to-open behavior and animation tuning.
- Launch at login support.
- Global toggle hotkey and notch-scoped shortcut handling.
- Terminal-friendly macOS key behavior for `Command+Arrow`, `Option+Arrow`, `Command+Backspace`, and `Command+L`.
## Gallery
### Demo
[![CommandNotch demo video](.github/assets/CommandNotch-Open.png)](.github/assets/CommandNotch-Demo.mp4)
Click the preview above to watch the demo recording.
### Open Notch Terminal
![CommandNotch open notch terminal](.github/assets/CommandNotch-Open.png)
### Settings
![CommandNotch settings window](.github/assets/CommandNotch-Settings.png)
## Getting Started
### Requirements
- macOS 14 or later
- Xcode 16 or later
- Homebrew `xcodegen`
### Build
```bash
cd CommandNotch
xcodegen generate --spec project.yml
open CommandNotch.xcodeproj
```
Or from the command line:
```bash
cd CommandNotch
xcodegen generate --spec project.yml
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer \
xcodebuild build -project CommandNotch.xcodeproj -scheme CommandNotch -destination 'platform=macOS'
```
## Project Layout
```text
CommandNotch/
├── CommandNotch/ # XcodeGen spec, app target, tests
├── docs/ # architecture and planning notes
├── icons/ # app icons and branding assets
└── .github/assets/ # README screenshots and support assets
```
## Contributing
Contributions are welcome. If you want to fix bugs, improve the UX, tighten the architecture, or help polish the public release, start with [CONTRIBUTING.md](CONTRIBUTING.md).
## Help Fund Development
If CommandNotch saves you time, support helps justify more polish and faster iteration.
- Ko-fi: https://ko-fi.com/harvmaster
- BCH: `bitcoincash:zq5xlhahsk8svzk562m3kwrzgd9hrm80mcu8slnzv3`
<p align="center">
<img src=".github/assets/bch-receiving-address.png" width="220" alt="BCH receiving address QR code">
</p>
## License
CommandNotch is released under the [MIT License](LICENSE).

View File

@@ -618,7 +618,7 @@ Exit criteria:
## Proposed File Layout
```text
Downterm/CommandNotch/
CommandNotch/CommandNotch/
App/
CommandNotchApp.swift
AppDelegate.swift

View File

@@ -321,12 +321,12 @@ The first deliverable should not include:
## Files Likely Impacted
- `Downterm/CommandNotch/Models/WorkspaceController.swift`
- `Downterm/CommandNotch/Models/WorkspaceRegistry.swift`
- `Downterm/CommandNotch/Models/WorkspaceStore.swift`
- `Downterm/CommandNotch/ContentView.swift`
- `Downterm/CommandNotch/Components/TabBar.swift`
- `Downterm/CommandNotch/Models/TerminalSession.swift`
- `CommandNotch/CommandNotch/Models/WorkspaceController.swift`
- `CommandNotch/CommandNotch/Models/WorkspaceRegistry.swift`
- `CommandNotch/CommandNotch/Models/WorkspaceStore.swift`
- `CommandNotch/CommandNotch/ContentView.swift`
- `CommandNotch/CommandNotch/Components/TabBar.swift`
- `CommandNotch/CommandNotch/Models/TerminalSession.swift`
- workspace-related tests
Likely new files: