From 01cf4fe930ee239a92dc23fb212d1b359e1c2e31 Mon Sep 17 00:00:00 2001 From: daniel Date: Tue, 25 Aug 2026 13:26:56 +0100 Subject: [PATCH] feat: add native macOS desktop app --- Cargo.lock | 2 +- Cargo.toml | 2 +- .../issues/subagent-session-stays-starting.md | 7 + fixtures/mock-acp.py | 48 +- macos/.gitignore | 3 + macos/KitDesktop.xcodeproj/project.pbxproj | 510 +++++++++++++++++ .../contents.xcworkspacedata | 7 + .../xcschemes/KitDesktop.xcscheme | 112 ++++ macos/KitDesktop/App/AppDelegate.swift | 19 + macos/KitDesktop/App/KitDesktopApp.swift | 23 + macos/KitDesktop/Models/AppModel.swift | 150 +++++ macos/KitDesktop/Models/AppState.swift | 185 ++++++ .../Models/ConversationController.swift | 456 +++++++++++++++ macos/KitDesktop/Services/ACPClient.swift | 539 ++++++++++++++++++ .../KitDesktop/Services/JSONLineParser.swift | 101 ++++ .../Services/PersistenceStore.swift | 89 +++ macos/KitDesktop/Views/ContentView.swift | 539 ++++++++++++++++++ macos/KitDesktop/Views/MarkdownView.swift | 194 +++++++ macos/KitDesktopTests/ACPProcessTests.swift | 163 ++++++ macos/KitDesktopTests/ACPProtocolTests.swift | 86 +++ macos/KitDesktopTests/AppModelTests.swift | 40 ++ .../KitDesktopTests/JSONLineParserTests.swift | 29 + .../MarkdownDocumentTests.swift | 33 ++ .../PersistenceStoreTests.swift | 90 +++ macos/README.md | 78 +++ macos/project.yml | 90 +++ 26 files changed, 3592 insertions(+), 3 deletions(-) create mode 100644 docs/issues/subagent-session-stays-starting.md create mode 100644 macos/.gitignore create mode 100644 macos/KitDesktop.xcodeproj/project.pbxproj create mode 100644 macos/KitDesktop.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 macos/KitDesktop.xcodeproj/xcshareddata/xcschemes/KitDesktop.xcscheme create mode 100644 macos/KitDesktop/App/AppDelegate.swift create mode 100644 macos/KitDesktop/App/KitDesktopApp.swift create mode 100644 macos/KitDesktop/Models/AppModel.swift create mode 100644 macos/KitDesktop/Models/AppState.swift create mode 100644 macos/KitDesktop/Models/ConversationController.swift create mode 100644 macos/KitDesktop/Services/ACPClient.swift create mode 100644 macos/KitDesktop/Services/JSONLineParser.swift create mode 100644 macos/KitDesktop/Services/PersistenceStore.swift create mode 100644 macos/KitDesktop/Views/ContentView.swift create mode 100644 macos/KitDesktop/Views/MarkdownView.swift create mode 100644 macos/KitDesktopTests/ACPProcessTests.swift create mode 100644 macos/KitDesktopTests/ACPProtocolTests.swift create mode 100644 macos/KitDesktopTests/AppModelTests.swift create mode 100644 macos/KitDesktopTests/JSONLineParserTests.swift create mode 100644 macos/KitDesktopTests/MarkdownDocumentTests.swift create mode 100644 macos/KitDesktopTests/PersistenceStoreTests.swift create mode 100644 macos/README.md create mode 100644 macos/project.yml diff --git a/Cargo.lock b/Cargo.lock index 4b506f1..b383c15 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2418,7 +2418,7 @@ dependencies = [ [[package]] name = "kit" -version = "0.1.86" +version = "0.1.87" dependencies = [ "a2a-protocol-client", "a2a-protocol-server", diff --git a/Cargo.toml b/Cargo.toml index a49773e..cf4ab34 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "kit" -version = "0.1.86" +version = "0.1.87" edition = "2024" rust-version = "1.94.0" publish = false diff --git a/docs/issues/subagent-session-stays-starting.md b/docs/issues/subagent-session-stays-starting.md new file mode 100644 index 0000000..a2ce203 --- /dev/null +++ b/docs/issues/subagent-session-stays-starting.md @@ -0,0 +1,7 @@ +# Subagent session can stay in `starting` indefinitely + +While implementing the macOS desktop app with Kit 0.1.86, a final review subagent remained in `starting` and never accepted work. Closing completed sibling sessions did not unblock it. Cancelling that session and creating a fresh subagent produced the same behavior. + +This creates friction because `subagents` provides no failure reason or timeout for the stuck startup, so the parent cannot distinguish queueing from a failed harness launch. The parent must abandon the review or repeatedly cancel and retry. + +Expected behavior: a subagent either starts within a bounded interval or transitions to a failed state with a diagnostic that explains the capacity or harness problem. diff --git a/fixtures/mock-acp.py b/fixtures/mock-acp.py index b791a1d..b3f9f6e 100644 --- a/fixtures/mock-acp.py +++ b/fixtures/mock-acp.py @@ -1,5 +1,7 @@ #!/usr/bin/env python3 import json +import os +import subprocess import sys import threading import time @@ -11,6 +13,11 @@ selected_models = {} model_ids = ["mock/default", "mock/requested"] +if os.environ.get("MOCK_CHILD_PID_FILE"): + child = subprocess.Popen([sys.executable, "-c", "import signal,time; signal.signal(signal.SIGTERM, signal.SIG_IGN); time.sleep(60)"] ) + with open(os.environ["MOCK_CHILD_PID_FILE"], "w") as file: + file.write(str(child.pid)) + def send(message): with write_lock: @@ -25,12 +32,16 @@ def respond(request_id, result): def prompt(request): params = request["params"] session_id = params["sessionId"] - text = params["prompt"][0]["text"] + text = next((block.get("text", "") for block in params["prompt"] if block.get("type") == "text"), "") + if "MOCK_HANG" in text: + return time.sleep(0.40) if "MOCK_SELECTED_MODEL" in text: text = selected_models.get(session_id, model_ids[0]) if "MOCK_STRUCTURED_OUTPUT" in text: text = json.dumps({"approved": True, "reason": "mock approved"}) + if "MOCK_MEDIA" in text: + text = ",".join(block.get("type", "unknown") for block in params["prompt"]) if "MOCK_RICH_OUTPUT" in text: updates = [ { @@ -69,6 +80,12 @@ def prompt(request): "method": "session/update", "params": {"sessionId": session_id, "update": update}, }) + if update["sessionUpdate"] == "tool_call": + sys.stderr.write("\x01kit-runtime\x01" + json.dumps({"event": "child_started", "call": "call-1:compose:shell", "tool": "shell", "summary": "echo mock", "at": 1}) + "\n") + sys.stderr.flush() + elif update["sessionUpdate"] == "tool_call_update": + sys.stderr.write("\x01kit-runtime\x01" + json.dumps({"event": "child_finished", "call": "call-1:compose:shell", "tool": "shell", "ok": True, "summary": "done", "millis": 2}) + "\nmock diagnostic\n") + sys.stderr.flush() text = "rich done" send({ "jsonrpc": "2.0", @@ -99,6 +116,10 @@ def prompt(request): elif method == "session/new": selected_models["base"] = model_ids[0] result = {"sessionId": "base"} + if os.environ.get("MOCK_EXIT_TAIL"): + sys.stdout.write(json.dumps({"jsonrpc": "2.0", "id": request["id"], "result": result})) + sys.stdout.flush() + os._exit(0) if supports_models: result["configOptions"] = [{ "id": "model", @@ -111,6 +132,31 @@ def prompt(request): ], }] respond(request["id"], result) + send({"jsonrpc": "2.0", "method": "session/update", "params": { + "sessionId": result["sessionId"], + "update": {"sessionUpdate": "available_commands_update", "availableCommands": [ + {"name": "compact", "description": "Compact the session context"} + ]}, + }}) + elif method == "session/load": + session_id = request["params"]["sessionId"] + selected_models[session_id] = model_ids[0] + for update in [ + {"sessionUpdate": "user_message_chunk", "content": {"type": "text", "text": "replayed user"}}, + {"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": "replayed assistant"}}, + ]: + send({"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": session_id, "update": update}}) + result = {} + if supports_models: + result["configOptions"] = [{ + "id": "model", "name": "Model", "category": "model", "type": "select", + "currentValue": model_ids[0], "options": [{"value": value, "name": value} for value in model_ids], + }] + respond(request["id"], result) + send({"jsonrpc": "2.0", "method": "session/update", "params": { + "sessionId": session_id, + "update": {"sessionUpdate": "available_commands_update", "availableCommands": [{"name": "compact", "description": "Compact the session context"}]}, + }}) elif method == "session/fork": if not supports_fork: send({ diff --git a/macos/.gitignore b/macos/.gitignore new file mode 100644 index 0000000..215f002 --- /dev/null +++ b/macos/.gitignore @@ -0,0 +1,3 @@ +.build/ +*.xcuserstate +xcuserdata/ diff --git a/macos/KitDesktop.xcodeproj/project.pbxproj b/macos/KitDesktop.xcodeproj/project.pbxproj new file mode 100644 index 0000000..07b7779 --- /dev/null +++ b/macos/KitDesktop.xcodeproj/project.pbxproj @@ -0,0 +1,510 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 02871F767925761E25F86B3E /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B3990BD2C27260A0E70B93A /* AppState.swift */; }; + 0F21F855B7DA35AD722AC7D3 /* ACPProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A49E707898C1B36BB27BC121 /* ACPProtocolTests.swift */; }; + 19B3A6B5BBEE1564C7F4AC0B /* PersistenceStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06F974BED9068ADA02CD72BC /* PersistenceStore.swift */; }; + 489E18B321109F8D9F512822 /* ACPClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D1610600D0AFF53C50D6E59 /* ACPClient.swift */; }; + 5D90E450684ED3EE71B69332 /* ConversationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F52157E3DA76808946939C19 /* ConversationController.swift */; }; + 6A73208124C5F3743BD70F5F /* ACPProcessTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7673B303F19B3783B1B84A34 /* ACPProcessTests.swift */; }; + 8E8A10F49B153622CF7DD7FA /* JSONLineParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11C475A19EDCF4F131882AF2 /* JSONLineParser.swift */; }; + 9C009A5EE85704A96BECA25C /* MarkdownDocumentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3755EFD0DF6570079CB1E9F /* MarkdownDocumentTests.swift */; }; + AA4D62C2F061DA87F223B926 /* KitDesktopApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 612469AB29D1589621F28D29 /* KitDesktopApp.swift */; }; + B03EA9D8EB2F591C684D3D3F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8F619B802566CBF11E36094 /* AppDelegate.swift */; }; + B9AC4DF35B2F9D766761C7EB /* JSONLineParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0670A97F6D40D534E10DF6B /* JSONLineParserTests.swift */; }; + D84D1F89B4EAA779010CFE3E /* AppModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAEBE305CCB3F66B4C6EFE46 /* AppModelTests.swift */; }; + E0A3CEF798311F487DD36E56 /* PersistenceStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 319931620251E086D0C605C3 /* PersistenceStoreTests.swift */; }; + E51D677C599E9529D12CC62C /* AppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C69138F9241277AF871FBF68 /* AppModel.swift */; }; + F1CCCF9D9F727E59B384BACC /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B793DB8C6D3587B43CDC552 /* ContentView.swift */; }; + FCEE0B2CC5DBD6B5F32DD243 /* MarkdownView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAEAA8A329489C8BD38EA402 /* MarkdownView.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 0DD249DBEFC0503246C7B188 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 2025A51ACF291CB2F82A0169 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 7ED0BA523F3C4C8EA87E4D9F; + remoteInfo = KitDesktop; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + 05719A7B769128EEEAC1EB27 /* KitDesktop.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = KitDesktop.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 06F974BED9068ADA02CD72BC /* PersistenceStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceStore.swift; sourceTree = ""; }; + 0C0AB706DCD4E682E3D144F0 /* KitDesktopTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = KitDesktopTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 11C475A19EDCF4F131882AF2 /* JSONLineParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONLineParser.swift; sourceTree = ""; }; + 2B3990BD2C27260A0E70B93A /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; + 319931620251E086D0C605C3 /* PersistenceStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceStoreTests.swift; sourceTree = ""; }; + 612469AB29D1589621F28D29 /* KitDesktopApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KitDesktopApp.swift; sourceTree = ""; }; + 7673B303F19B3783B1B84A34 /* ACPProcessTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ACPProcessTests.swift; sourceTree = ""; }; + 7D1610600D0AFF53C50D6E59 /* ACPClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ACPClient.swift; sourceTree = ""; }; + 9B793DB8C6D3587B43CDC552 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + A49E707898C1B36BB27BC121 /* ACPProtocolTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ACPProtocolTests.swift; sourceTree = ""; }; + C69138F9241277AF871FBF68 /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModel.swift; sourceTree = ""; }; + D3755EFD0DF6570079CB1E9F /* MarkdownDocumentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownDocumentTests.swift; sourceTree = ""; }; + DAEBE305CCB3F66B4C6EFE46 /* AppModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModelTests.swift; sourceTree = ""; }; + E8F619B802566CBF11E36094 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + EAEAA8A329489C8BD38EA402 /* MarkdownView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownView.swift; sourceTree = ""; }; + F0670A97F6D40D534E10DF6B /* JSONLineParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONLineParserTests.swift; sourceTree = ""; }; + F52157E3DA76808946939C19 /* ConversationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationController.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXGroup section */ + 232CD4D33E52B3C1A1E3397E /* Views */ = { + isa = PBXGroup; + children = ( + 9B793DB8C6D3587B43CDC552 /* ContentView.swift */, + EAEAA8A329489C8BD38EA402 /* MarkdownView.swift */, + ); + path = Views; + sourceTree = ""; + }; + 3CDADD1A6A9747B8D7979EE4 /* Products */ = { + isa = PBXGroup; + children = ( + 05719A7B769128EEEAC1EB27 /* KitDesktop.app */, + 0C0AB706DCD4E682E3D144F0 /* KitDesktopTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 41D6646C8AAC0B7036DB3442 /* Models */ = { + isa = PBXGroup; + children = ( + C69138F9241277AF871FBF68 /* AppModel.swift */, + 2B3990BD2C27260A0E70B93A /* AppState.swift */, + F52157E3DA76808946939C19 /* ConversationController.swift */, + ); + path = Models; + sourceTree = ""; + }; + 482ECD65BDFE3723C98BF66D = { + isa = PBXGroup; + children = ( + B364810B2CEDCAFDFF4E5FC3 /* KitDesktop */, + 9D22957B08B56139F83F43FC /* KitDesktopTests */, + 3CDADD1A6A9747B8D7979EE4 /* Products */, + ); + sourceTree = ""; + }; + 72120F610CA996BBCDA27324 /* Services */ = { + isa = PBXGroup; + children = ( + 7D1610600D0AFF53C50D6E59 /* ACPClient.swift */, + 11C475A19EDCF4F131882AF2 /* JSONLineParser.swift */, + 06F974BED9068ADA02CD72BC /* PersistenceStore.swift */, + ); + path = Services; + sourceTree = ""; + }; + 9D22957B08B56139F83F43FC /* KitDesktopTests */ = { + isa = PBXGroup; + children = ( + 7673B303F19B3783B1B84A34 /* ACPProcessTests.swift */, + A49E707898C1B36BB27BC121 /* ACPProtocolTests.swift */, + DAEBE305CCB3F66B4C6EFE46 /* AppModelTests.swift */, + F0670A97F6D40D534E10DF6B /* JSONLineParserTests.swift */, + D3755EFD0DF6570079CB1E9F /* MarkdownDocumentTests.swift */, + 319931620251E086D0C605C3 /* PersistenceStoreTests.swift */, + ); + path = KitDesktopTests; + sourceTree = ""; + }; + B364810B2CEDCAFDFF4E5FC3 /* KitDesktop */ = { + isa = PBXGroup; + children = ( + C18AE6A3327A1061BE966290 /* App */, + 41D6646C8AAC0B7036DB3442 /* Models */, + 72120F610CA996BBCDA27324 /* Services */, + 232CD4D33E52B3C1A1E3397E /* Views */, + ); + path = KitDesktop; + sourceTree = ""; + }; + C18AE6A3327A1061BE966290 /* App */ = { + isa = PBXGroup; + children = ( + E8F619B802566CBF11E36094 /* AppDelegate.swift */, + 612469AB29D1589621F28D29 /* KitDesktopApp.swift */, + ); + path = App; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 7ED0BA523F3C4C8EA87E4D9F /* KitDesktop */ = { + isa = PBXNativeTarget; + buildConfigurationList = 3A0D94AADBAA81ABC91E417F /* Build configuration list for PBXNativeTarget "KitDesktop" */; + buildPhases = ( + E60384F9B9BF39AA6162E76D /* Sources */, + 62A37A10346635E51D704C0B /* Copy Kit CLI */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = KitDesktop; + packageProductDependencies = ( + ); + productName = KitDesktop; + productReference = 05719A7B769128EEEAC1EB27 /* KitDesktop.app */; + productType = "com.apple.product-type.application"; + }; + B27184438FE2179D705C8930 /* KitDesktopTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 54A80D59B70EDEF0A4EECD04 /* Build configuration list for PBXNativeTarget "KitDesktopTests" */; + buildPhases = ( + C65F79BCDB4546368CF3E5FF /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + B2DBBAB55E23ACB9DC06977E /* PBXTargetDependency */, + ); + name = KitDesktopTests; + packageProductDependencies = ( + ); + productName = KitDesktopTests; + productReference = 0C0AB706DCD4E682E3D144F0 /* KitDesktopTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 2025A51ACF291CB2F82A0169 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1430; + TargetAttributes = { + 7ED0BA523F3C4C8EA87E4D9F = { + ProvisioningStyle = Automatic; + }; + B27184438FE2179D705C8930 = { + ProvisioningStyle = Automatic; + }; + }; + }; + buildConfigurationList = 4C2CC01BB409B1330CBD0CE8 /* Build configuration list for PBXProject "KitDesktop" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + en, + ); + mainGroup = 482ECD65BDFE3723C98BF66D; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = 3CDADD1A6A9747B8D7979EE4 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 7ED0BA523F3C4C8EA87E4D9F /* KitDesktop */, + B27184438FE2179D705C8930 /* KitDesktopTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXShellScriptBuildPhase section */ + 62A37A10346635E51D704C0B /* Copy Kit CLI */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "$(SRCROOT)/../Cargo.toml", + "$(SRCROOT)/../Cargo.lock", + ); + name = "Copy Kit CLI"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(TARGET_BUILD_DIR)/$(CONTENTS_FOLDER_PATH)/Helpers/kit", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "set -eu\nhelper_dir=\"${TARGET_BUILD_DIR}/${CONTENTS_FOLDER_PATH}/Helpers\"\ndestination=\"${helper_dir}/kit\"\nrm -f \"${destination}\"\nmkdir -p \"${helper_dir}\"\nsource_binary=\"${KIT_BINARY:-}\"\nif [ -z \"${source_binary}\" ]; then\n if [ \"${CONFIGURATION}\" = \"Release\" ]; then\n source_binary=\"${SRCROOT}/../target/release/kit\"\n else\n source_binary=\"${SRCROOT}/../target/debug/kit\"\n fi\nfi\nif [ ! -x \"${source_binary}\" ]; then\n if [ \"${CONFIGURATION}\" = \"Release\" ]; then\n echo \"error: Release requires an optimized Kit helper. Run: cargo build --locked --release --bin kit\" >&2\n exit 1\n fi\n echo \"warning: Debug Kit helper unavailable; KIT_BINARY/source-tree/PATH runtime fallback remains enabled.\"\n exit 0\nfi\nif [ \"${CONFIGURATION}\" = \"Release\" ] && printf '%s' \"${source_binary}\" | grep -q '/target/debug/'; then\n echo \"error: refusing to package a Debug Kit helper in Release\" >&2\n exit 1\nfi\nhelper_arches=\"$(lipo -archs \"${source_binary}\")\"\nfor required_arch in ${ARCHS}; do\n if ! printf ' %s ' \"${helper_arches}\" | grep -q \" ${required_arch} \"; then\n echo \"error: Kit helper architectures (${helper_arches}) do not include ${required_arch}\" >&2\n exit 1\n fi\ndone\ncp \"${source_binary}\" \"${destination}\"\nchmod +x \"${destination}\"\n"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + C65F79BCDB4546368CF3E5FF /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 6A73208124C5F3743BD70F5F /* ACPProcessTests.swift in Sources */, + 0F21F855B7DA35AD722AC7D3 /* ACPProtocolTests.swift in Sources */, + D84D1F89B4EAA779010CFE3E /* AppModelTests.swift in Sources */, + B9AC4DF35B2F9D766761C7EB /* JSONLineParserTests.swift in Sources */, + 9C009A5EE85704A96BECA25C /* MarkdownDocumentTests.swift in Sources */, + E0A3CEF798311F487DD36E56 /* PersistenceStoreTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + E60384F9B9BF39AA6162E76D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 489E18B321109F8D9F512822 /* ACPClient.swift in Sources */, + B03EA9D8EB2F591C684D3D3F /* AppDelegate.swift in Sources */, + E51D677C599E9529D12CC62C /* AppModel.swift in Sources */, + 02871F767925761E25F86B3E /* AppState.swift in Sources */, + F1CCCF9D9F727E59B384BACC /* ContentView.swift in Sources */, + 5D90E450684ED3EE71B69332 /* ConversationController.swift in Sources */, + 8E8A10F49B153622CF7DD7FA /* JSONLineParser.swift in Sources */, + AA4D62C2F061DA87F223B926 /* KitDesktopApp.swift in Sources */, + FCEE0B2CC5DBD6B5F32DD243 /* MarkdownView.swift in Sources */, + 19B3A6B5BBEE1564C7F4AC0B /* PersistenceStore.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + B2DBBAB55E23ACB9DC06977E /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 7ED0BA523F3C4C8EA87E4D9F /* KitDesktop */; + targetProxy = 0DD249DBEFC0503246C7B188 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 10D9E04E5F998477B6B45FCD /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + COMBINE_HIDPI_IMAGES = YES; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@loader_path/../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = dev.kit.desktop.tests; + SDKROOT = macosx; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Kit.app/Contents/MacOS/Kit"; + }; + name = Release; + }; + 761E3467BF48A54305E28AAB /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 7C10311BFF4CCE1F7F5FEF88 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + COMBINE_HIDPI_IMAGES = YES; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@loader_path/../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = dev.kit.desktop.tests; + SDKROOT = macosx; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Kit.app/Contents/MacOS/Kit"; + }; + name = Debug; + }; + 860B938CB91EEAF3F720EBDB /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "DEBUG=1", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + B2009F1150B98FAFD2B39B02 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + COMBINE_HIDPI_IMAGES = YES; + ENABLE_APP_SANDBOX = NO; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = Kit; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = dev.kit.desktop; + PRODUCT_NAME = Kit; + SDKROOT = macosx; + }; + name = Release; + }; + D33D6F616CA6AEDA5C2E24A6 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + COMBINE_HIDPI_IMAGES = YES; + ENABLE_APP_SANDBOX = NO; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = Kit; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = dev.kit.desktop; + PRODUCT_NAME = Kit; + SDKROOT = macosx; + }; + name = Debug; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 3A0D94AADBAA81ABC91E417F /* Build configuration list for PBXNativeTarget "KitDesktop" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + D33D6F616CA6AEDA5C2E24A6 /* Debug */, + B2009F1150B98FAFD2B39B02 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + 4C2CC01BB409B1330CBD0CE8 /* Build configuration list for PBXProject "KitDesktop" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 860B938CB91EEAF3F720EBDB /* Debug */, + 761E3467BF48A54305E28AAB /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + 54A80D59B70EDEF0A4EECD04 /* Build configuration list for PBXNativeTarget "KitDesktopTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 7C10311BFF4CCE1F7F5FEF88 /* Debug */, + 10D9E04E5F998477B6B45FCD /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; +/* End XCConfigurationList section */ + }; + rootObject = 2025A51ACF291CB2F82A0169 /* Project object */; +} diff --git a/macos/KitDesktop.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/macos/KitDesktop.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/macos/KitDesktop.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/macos/KitDesktop.xcodeproj/xcshareddata/xcschemes/KitDesktop.xcscheme b/macos/KitDesktop.xcodeproj/xcshareddata/xcschemes/KitDesktop.xcscheme new file mode 100644 index 0000000..b6c24e3 --- /dev/null +++ b/macos/KitDesktop.xcodeproj/xcshareddata/xcschemes/KitDesktop.xcscheme @@ -0,0 +1,112 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/KitDesktop/App/AppDelegate.swift b/macos/KitDesktop/App/AppDelegate.swift new file mode 100644 index 0000000..105a844 --- /dev/null +++ b/macos/KitDesktop/App/AppDelegate.swift @@ -0,0 +1,19 @@ +import AppKit + +final class AppDelegate: NSObject, NSApplicationDelegate { + var shutdown: (((@escaping () -> Void) -> Void))? + private var replySent = false + + func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { + guard let shutdown else { return .terminateNow } + replySent = false + let finish = { [weak self] in + guard let self, !self.replySent else { return } + self.replySent = true + sender.reply(toApplicationShouldTerminate: true) + } + shutdown(finish) + DispatchQueue.main.asyncAfter(deadline: .now() + 10, execute: finish) + return .terminateLater + } +} diff --git a/macos/KitDesktop/App/KitDesktopApp.swift b/macos/KitDesktop/App/KitDesktopApp.swift new file mode 100644 index 0000000..a03cf60 --- /dev/null +++ b/macos/KitDesktop/App/KitDesktopApp.swift @@ -0,0 +1,23 @@ +import SwiftUI + +@main +struct KitDesktopApp: App { + @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate + @StateObject private var model = AppModel() + + var body: some Scene { + WindowGroup { + ContentView() + .environmentObject(model) + .onAppear { appDelegate.shutdown = { completion in model.closeAll(completion: completion) } } + .frame(minWidth: 980, minHeight: 640) + } + .commands { + CommandGroup(after: .newItem) { + Button("New Conversation") { model.createConversation() } + .keyboardShortcut("n", modifiers: [.command, .shift]) + .disabled(model.selectedWorkspaceID == nil) + } + } + } +} diff --git a/macos/KitDesktop/Models/AppModel.swift b/macos/KitDesktop/Models/AppModel.swift new file mode 100644 index 0000000..ab46d9c --- /dev/null +++ b/macos/KitDesktop/Models/AppModel.swift @@ -0,0 +1,150 @@ +import AppKit +import Foundation +import UserNotifications + +@MainActor +final class AppModel: ObservableObject { + @Published private(set) var state: PersistedAppState + @Published private(set) var controllers: [UUID: ConversationController] = [:] + @Published private(set) var activity: [UUID: Bool] = [:] + @Published var selectedWorkspaceID: UUID? + @Published var selectedConversationID: UUID? + @Published var persistenceError: String? + + private let store: PersistenceStore + + init(store: PersistenceStore = PersistenceStore()) { + self.store = store + do { state = try store.load() } + catch { state = PersistedAppState(); persistenceError = error.localizedDescription } + selectedWorkspaceID = state.workspaces.first?.id + UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { _, _ in } + } + + var selectedWorkspace: Workspace? { state.workspaces.first { $0.id == selectedWorkspaceID } } + var selectedController: ConversationController? { selectedConversationID.flatMap { controllers[$0] } } + + var workspaceConversations: [Conversation] { + state.conversations.filter { $0.workspaceID == selectedWorkspaceID }.sorted { $0.updatedAt > $1.updatedAt } + } + + func addWorkspace(path: String) { + let standardized = URL(fileURLWithPath: path).standardizedFileURL.path + if let existing = state.workspaces.first(where: { $0.path == standardized }) { selectWorkspace(existing.id); return } + let name = URL(fileURLWithPath: standardized).lastPathComponent + let workspace = Workspace(name: name.isEmpty ? standardized : name, path: standardized) + state.workspaces.append(workspace) + selectedWorkspaceID = workspace.id + selectedConversationID = nil + save() + } + + func selectWorkspace(_ id: UUID?) { + selectedWorkspaceID = id + if let selectedConversationID, !state.conversations.contains(where: { $0.id == selectedConversationID && $0.workspaceID == id }) { self.selectedConversationID = nil } + } + + func createConversation() { + guard let workspaceID = selectedWorkspaceID else { return } + let conversation = Conversation(workspaceID: workspaceID) + state.conversations.append(conversation) + save() + selectConversation(conversation.id) + } + + func selectConversation(_ id: UUID?) { + selectedConversationID = id + guard let id else { return } + updateConversation(id) { item in + item.unread = false + item.awaitingUser = false + } + UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [id.uuidString]) + openConversationIfNeeded(id) + } + + func appBecameActive() { + guard let id = selectedConversationID else { return } + updateConversation(id) { item in + item.unread = false + item.awaitingUser = false + } + UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [id.uuidString]) + } + + func closeAll(completion: (() -> Void)? = nil) { + store.flush() + let active = Array(controllers.values) + guard !active.isEmpty else { completion?(); return } + var remaining = active.count + for controller in active { + controller.close { + remaining -= 1 + if remaining == 0 { completion?() } + } + } + } + + private func openConversationIfNeeded(_ id: UUID) { + guard controllers[id] == nil, let conversation = state.conversations.first(where: { $0.id == id }), + let workspace = state.workspaces.first(where: { $0.id == conversation.workspaceID }) else { return } + let controller = ConversationController(conversation: conversation, workspacePath: workspace.path) + controller.onSessionReady = { [weak self] sessionID, _ in + self?.updateConversation(id) { item in item.sessionID = sessionID; item.updatedAt = Date() } + } + controller.onTurnStarted = { [weak self] prompt in + self?.updateConversation(id) { item in + item.awaitingUser = false; item.unread = false; item.updatedAt = Date() + if item.title == "New conversation" && !prompt.isEmpty { item.title = String(prompt.prefix(64)).replacingOccurrences(of: "\n", with: " ") } + } + } + controller.onTurnFinished = { [weak self] reason in self?.turnFinished(id: id, reason: reason) } + controller.onTitleChanged = { [weak self] title in self?.updateConversation(id) { $0.title = title } } + controller.onActivityChanged = { [weak self] active in self?.activity[id] = active } + controller.onConfigChanged = { [weak self] provider, model, effort, userSelected in + self?.updateConversation(id) { item in + item.provider = provider + item.model = model + item.reasoningEffort = effort + if userSelected { item.usesConfiguredDefaults = false } + } + } + controllers[id] = controller + activity[id] = false + controller.start() + } + + private func turnFinished(id: UUID, reason: String) { + let inactive = NSApp == nil || !NSApp.isActive + let hidden = selectedConversationID != id + let needsAttention = inactive || hidden + let attention = Self.attentionState(reason: reason, isFocused: !needsAttention) + updateConversation(id) { item in + item.awaitingUser = attention.awaitingUser + item.unread = attention.unread + item.updatedAt = Date() + } + guard inactive, let conversation = state.conversations.first(where: { $0.id == id }) else { return } + let content = UNMutableNotificationContent() + content.title = conversation.title + content.body = reason == "end_turn" ? "Kit finished and is awaiting your input." : "Kit turn finished: \(reason)." + content.sound = .default + UNUserNotificationCenter.current().add(UNNotificationRequest(identifier: id.uuidString, content: content, trigger: nil)) + } + + static func attentionState(reason: String, isFocused: Bool) -> (awaitingUser: Bool, unread: Bool) { + guard !isFocused else { return (false, false) } + return (reason == "end_turn", true) + } + + private func updateConversation(_ id: UUID, change: (inout Conversation) -> Void) { + guard let index = state.conversations.firstIndex(where: { $0.id == id }) else { return } + change(&state.conversations[index]) + save() + } + + private func save() { + let snapshot = state + store.saveAsync(snapshot) { [weak self] error in self?.persistenceError = error?.localizedDescription } + } +} diff --git a/macos/KitDesktop/Models/AppState.swift b/macos/KitDesktop/Models/AppState.swift new file mode 100644 index 0000000..dcc44ea --- /dev/null +++ b/macos/KitDesktop/Models/AppState.swift @@ -0,0 +1,185 @@ +import Foundation + +struct Workspace: Codable, Identifiable, Equatable { + let id: UUID + var name: String + var path: String + var createdAt: Date + + init(id: UUID = UUID(), name: String, path: String, createdAt: Date = Date()) { + self.id = id + self.name = name + self.path = path + self.createdAt = createdAt + } +} + +struct Conversation: Codable, Identifiable, Equatable { + let id: UUID + let workspaceID: UUID + var title: String + var sessionID: String? + var createdAt: Date + var updatedAt: Date + var unread: Bool + var awaitingUser: Bool + var provider: String + var model: String + var reasoningEffort: String + var usesConfiguredDefaults: Bool + + init( + id: UUID = UUID(), workspaceID: UUID, title: String = "New conversation", + sessionID: String? = nil, createdAt: Date = Date(), updatedAt: Date = Date(), + unread: Bool = false, awaitingUser: Bool = false, + provider: String = "openai-subscription", model: String = "gpt-5.4", + reasoningEffort: String = "default", usesConfiguredDefaults: Bool = true + ) { + self.id = id + self.workspaceID = workspaceID + self.title = title + self.sessionID = sessionID + self.createdAt = createdAt + self.updatedAt = updatedAt + self.unread = unread + self.awaitingUser = awaitingUser + self.provider = provider + self.model = model + self.reasoningEffort = reasoningEffort + self.usesConfiguredDefaults = usesConfiguredDefaults + } + + private enum CodingKeys: String, CodingKey { + case id, workspaceID, title, sessionID, createdAt, updatedAt, unread, awaitingUser + case provider, model, reasoningEffort, usesConfiguredDefaults + } + + init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + id = try values.decode(UUID.self, forKey: .id) + workspaceID = try values.decode(UUID.self, forKey: .workspaceID) + title = try values.decode(String.self, forKey: .title) + sessionID = try values.decodeIfPresent(String.self, forKey: .sessionID) + createdAt = try values.decode(Date.self, forKey: .createdAt) + updatedAt = try values.decode(Date.self, forKey: .updatedAt) + unread = try values.decodeIfPresent(Bool.self, forKey: .unread) ?? false + awaitingUser = try values.decodeIfPresent(Bool.self, forKey: .awaitingUser) ?? false + provider = try values.decodeIfPresent(String.self, forKey: .provider) ?? "openai-subscription" + model = try values.decodeIfPresent(String.self, forKey: .model) ?? "gpt-5.4" + reasoningEffort = try values.decodeIfPresent(String.self, forKey: .reasoningEffort) ?? "default" + // Schema 1 and 2 stored hard-coded desktop fallbacks as if they were user + // choices. Treat those records as inherited so the CLI can re-resolve config. + usesConfiguredDefaults = try values.decodeIfPresent(Bool.self, forKey: .usesConfiguredDefaults) ?? true + } +} + +struct PersistedAppState: Codable, Equatable { + static let currentSchemaVersion = 3 + var schemaVersion: Int + var workspaces: [Workspace] + var conversations: [Conversation] + + init(schemaVersion: Int = Self.currentSchemaVersion, workspaces: [Workspace] = [], conversations: [Conversation] = []) { + self.schemaVersion = schemaVersion + self.workspaces = workspaces + self.conversations = conversations + } + + private enum CodingKeys: String, CodingKey { case schemaVersion, workspaces, conversations } + + init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + schemaVersion = try values.decodeIfPresent(Int.self, forKey: .schemaVersion) ?? 1 + guard schemaVersion <= Self.currentSchemaVersion else { + throw PersistenceError.unsupportedSchema(schemaVersion) + } + workspaces = try values.decodeIfPresent([Workspace].self, forKey: .workspaces) ?? [] + conversations = try values.decodeIfPresent([Conversation].self, forKey: .conversations) ?? [] + schemaVersion = Self.currentSchemaVersion + } +} + +enum TranscriptRole: String { case user, assistant, thought, tool, plan, status, usage, error } + +struct RuntimeChild: Identifiable, Equatable { + let id: String + var tool: String + var summary: String + var running: Bool + var succeeded: Bool? + var durationMS: Int? +} + +struct TranscriptEntry: Identifiable { + let id: UUID + var role: TranscriptRole + var title: String? + var text: String + var toolCallID: String? + var isStreaming: Bool + var formatted: AttributedString? + var children: [RuntimeChild] + var backgrounded: Bool + + init( + id: UUID = UUID(), role: TranscriptRole, title: String? = nil, text: String, + toolCallID: String? = nil, isStreaming: Bool = false, formatted: AttributedString? = nil, + children: [RuntimeChild] = [], backgrounded: Bool = false + ) { + self.id = id + self.role = role + self.title = title + self.text = text + self.toolCallID = toolCallID + self.isStreaming = isStreaming + self.formatted = formatted + self.children = children + self.backgrounded = backgrounded + } +} + +enum AttachmentKind: String { case image, audio } + +struct Attachment: Identifiable, Equatable { + let id: UUID + let url: URL + let kind: AttachmentKind + let mimeType: String + let size: Int64 + + init(id: UUID = UUID(), url: URL, kind: AttachmentKind, mimeType: String, size: Int64) { + self.id = id + self.url = url + self.kind = kind + self.mimeType = mimeType + self.size = size + } +} + +struct ConfigChoice: Identifiable, Equatable { + var id: String { value } + let value: String + let name: String +} + +struct ConfigGroup: Identifiable, Equatable { + let id: String + let name: String + let choices: [ConfigChoice] +} + +struct ConfigOption: Identifiable, Equatable { + let id: String + var name: String + var category: String? + var currentValue: String + var groups: [ConfigGroup] + + var choices: [ConfigChoice] { groups.flatMap(\.choices) } +} + +struct AdvertisedCommand: Identifiable, Equatable { + var id: String { name } + let name: String + let description: String +} diff --git a/macos/KitDesktop/Models/ConversationController.swift b/macos/KitDesktop/Models/ConversationController.swift new file mode 100644 index 0000000..d568dd0 --- /dev/null +++ b/macos/KitDesktop/Models/ConversationController.swift @@ -0,0 +1,456 @@ +import AppKit +import Foundation + +@MainActor +final class ConversationController: ObservableObject { + static let maximumEntries = 1000 + static let maximumAttachmentCount = 8 + static let maximumAttachmentBytes: Int64 = 10 * 1024 * 1024 + static let maximumTotalAttachmentBytes: Int64 = 20 * 1024 * 1024 + + @Published var entries: [TranscriptEntry] = [] + @Published var configOptions: [ConfigOption] = [] + @Published var advertisedCommands: [AdvertisedCommand] = [] + @Published var draft = "" + @Published var attachments: [Attachment] = [] + @Published var diagnostics: [String] = [] + @Published var status = "Connecting…" + @Published var isReady = false + @Published var isRunning = false + @Published var contextUsed: Int? + @Published var contextSize: Int? + @Published var transcriptRevision = 0 + + let conversationID: UUID + var canCancel: Bool { foregroundRunning } + var acceptsInput: Bool { isReady && !foregroundRunning && autonomousTurns.isEmpty } + private let client: ACPClient + private let workspacePath: String + private let initialConversation: Conversation + private var foregroundRunning = false + private var autonomousTurns: Set = [] + private var streamingEntryIDs: Set = [] + private var latestAssistantSource = "" + + var onSessionReady: ((String, [ConfigOption]) -> Void)? + var onTurnStarted: ((String) -> Void)? + var onTurnFinished: ((String) -> Void)? + var onTitleChanged: ((String) -> Void)? + var onActivityChanged: ((Bool) -> Void)? + var onConfigChanged: ((String, String, String, Bool) -> Void)? + + init(conversation: Conversation, workspacePath: String, client: ACPClient = ACPClient()) { + conversationID = conversation.id + initialConversation = conversation + self.workspacePath = workspacePath + self.client = client + } + + func start() { + client.onUpdate = { [weak self] update in self?.apply(update) } + client.onRuntimeEvent = { [weak self] event in self?.applyRuntime(event) } + client.onDiagnostic = { [weak self] text in self?.recordDiagnostic(text) } + client.onExit = { [weak self] code in + guard let self else { return } + self.isReady = false + self.foregroundRunning = false + self.autonomousTurns.removeAll() + self.refreshActivity() + if self.status != "Closed" { self.status = "Kit exited (status \(code))" } + } + let persisted = initialConversation.sessionID + let sessionID = persisted ?? "s-desktop-\(UUID().uuidString.replacingOccurrences(of: "-", with: ""))" + let inheritsConfig = initialConversation.usesConfiguredDefaults + let options = ACPLaunchOptions( + root: workspacePath, sessionID: sessionID, resume: persisted != nil, + provider: inheritsConfig ? nil : initialConversation.provider, + model: inheritsConfig ? nil : initialConversation.model, + reasoningEffort: inheritsConfig ? nil : initialConversation.reasoningEffort + ) + client.start(options: options, loading: persisted != nil) { [weak self] result in + guard let self else { return } + switch result { + case .failure(let error): self.fail(error) + case .success(let payload): + self.configOptions = Self.parseConfigOptions(payload["configOptions"]) + self.isReady = true + self.status = persisted == nil ? "Ready" : "Continued session" + self.finishStreamingEntries() + let resolved = payload["sessionId"] as? String ?? sessionID + self.onSessionReady?(resolved, self.configOptions) + self.publishCurrentConfig() + } + } + } + + func send() { + let text = draft.trimmingCharacters(in: .whitespacesAndNewlines) + guard acceptsInput, !text.isEmpty || !attachments.isEmpty else { return } + let files = attachments + foregroundRunning = true + status = "Sending…" + refreshActivity() + client.prompt(text: text, attachments: files, onSent: { [weak self] in + guard let self else { return } + self.draft = "" + self.attachments = [] + self.latestAssistantSource = "" + self.status = "Running…" + let suffix = files.enumerated().map { index, file in "📎 \(file.kind == .image ? "Image" : "Audio") #\(index + 1): \(file.url.lastPathComponent)" }.joined(separator: "\n") + self.appendEntry(TranscriptEntry(role: .user, text: text + (suffix.isEmpty ? "" : (text.isEmpty ? "" : "\n") + suffix))) + self.finalizeLastEntry() + self.onTurnStarted?(text) + }) { [weak self] result in + guard let self else { return } + self.foregroundRunning = false + self.finishStreamingEntries() + self.refreshActivity() + switch result { + case .failure(let error): self.fail(error) + case .success(let payload): + let reason = payload["stopReason"] as? String ?? "completed" + self.status = reason == "cancelled" ? "Cancelled" : "Ready · \(reason.replacingOccurrences(of: "_", with: " "))" + self.onTurnFinished?(reason) + } + } + } + + func cancel() { + guard foregroundRunning else { return } + status = "Cancelling…" + client.cancel() + } + + func choose(_ option: ConfigOption, value: String) { + client.setConfig(id: option.id, value: value) { [weak self] result in + guard let self else { return } + switch result { + case .failure(let error): self.fail(error) + case .success(let payload): + let refreshed = Self.parseConfigOptions(payload["configOptions"]) + if !refreshed.isEmpty { self.configOptions = refreshed } + else if let index = self.configOptions.firstIndex(where: { $0.id == option.id }) { self.configOptions[index].currentValue = value } + self.publishCurrentConfig(userSelected: true) + } + } + } + + func detachCompose(callID: String) { + client.detachCompose(callID: callID) { [weak self] result in + guard let self else { return } + switch result { + case .failure(let error): self.fail(error) + case .success(let payload): + if payload["detached"] as? Bool == true, let index = self.entries.firstIndex(where: { $0.toolCallID == callID }) { + self.entries[index].backgrounded = true + self.status = "Compose call moved to background" + self.transcriptRevision += 1 + } + } + } + } + + func cancelBackground(callID: String) { + client.cancelBackground(callID: callID) { [weak self] result in + if case .failure(let error) = result { self?.fail(error) } + } + } + + func copyLastResponse() { + guard !latestAssistantSource.isEmpty else { status = "No assistant response to copy"; return } + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(latestAssistantSource, forType: .string) + status = "Copied latest assistant response as Markdown" + } + + func addAttachments(_ urls: [URL]) { + var next = attachments + for url in urls where !next.contains(where: { $0.url == url }) { + guard next.count < Self.maximumAttachmentCount else { fail(ACPClientError.attachment("At most 8 attachments can be pending")); break } + do { next.append(try Self.attachment(url)) } catch { fail(error) } + } + let total = next.reduce(Int64(0)) { $0 + $1.size } + guard total <= Self.maximumTotalAttachmentBytes else { fail(ACPClientError.attachment("Attachments exceed the 20 MiB total limit")); return } + attachments = next + } + + func removeAttachment(_ id: UUID) { attachments.removeAll { $0.id == id } } + + func close(completion: (() -> Void)? = nil) { + status = "Closing…" + client.close(activeTurn: foregroundRunning || !autonomousTurns.isEmpty, completion: completion) + isReady = false + } + + private func apply(_ update: [String: Any]) { + let kind = update["sessionUpdate"] as? String ?? "unknown" + switch kind { + case "user_message_chunk": + latestAssistantSource = "" + appendChunk(role: .user, text: Self.contentText(update["content"])) + case "agent_message_chunk": + let text = Self.contentText(update["content"]); latestAssistantSource += text + appendChunk(role: .assistant, text: text) + case "agent_thought_chunk": appendChunk(role: .thought, text: Self.contentText(update["content"])) + case "tool_call": + appendEntry(TranscriptEntry( + role: .tool, title: update["title"] as? String ?? "Tool", + text: Self.describe(update["rawInput"] ?? update["content"]), + toolCallID: update["toolCallId"] as? String, isStreaming: true, + backgrounded: (update["rawInput"] as? [String: Any])?["background"] as? Bool == true + )) + if let id = entries.last?.id { streamingEntryIDs.insert(id) }; refreshActivity() + case "tool_call_update": updateTool(update) + case "plan": + let rows = (update["entries"] as? [[String: Any]] ?? []).map { "[\($0["status"] as? String ?? "pending")] \($0["content"] as? String ?? "")" } + upsertSingleton(role: .plan, title: "Plan", text: rows.joined(separator: "\n")) + case "usage_update": + // Context is persistent conversation chrome, not a transcript message. + contextUsed = (update["used"] as? NSNumber)?.intValue + contextSize = (update["size"] as? NSNumber)?.intValue + case "config_option_update": configOptions = Self.parseConfigOptions(update["configOptions"]); publishCurrentConfig() + case "session_info_update": if let title = update["title"] as? String { onTitleChanged?(title) } + case "available_commands_update": + advertisedCommands = (update["availableCommands"] as? [[String: Any]] ?? []).compactMap { item in + guard let name = item["name"] as? String else { return nil } + return AdvertisedCommand(name: name, description: item["description"] as? String ?? "") + } + case "status": applyTurnState(update) + default: appendEntry(TranscriptEntry(role: .status, title: kind.replacingOccurrences(of: "_", with: " "), text: Self.describe(update))) + } + } + + private func applyTurnState(_ update: [String: Any]) { + let turn = (update["turn_id"] as? NSNumber)?.intValue ?? -1 + let active = update["active"] as? Bool ?? false + let error = update["error"] as? String + if active { + autonomousTurns.insert(turn) + onTurnStarted?("") + } else { autonomousTurns.remove(turn) } + status = error ?? (active ? "Background turn started" : "Background turn finished") + upsertSingleton(role: .status, title: "Status", text: status) + if !active { finishStreamingEntries(); onTurnFinished?(error == nil ? "end_turn" : "error") } + refreshActivity() + } + + private func applyRuntime(_ event: [String: Any]) { + guard let kind = event["event"] as? String else { return } + switch kind { + case "child_started": + guard let call = event["call"] as? String, let parent = Self.parentCall(call), let index = entries.firstIndex(where: { $0.toolCallID == parent }) else { return } + entries[index].children.append(RuntimeChild(id: call, tool: event["tool"] as? String ?? "tool", summary: event["summary"] as? String ?? "", running: true, succeeded: nil, durationMS: nil)) + case "child_finished": + guard let call = event["call"] as? String, let parent = Self.parentCall(call), let entry = entries.firstIndex(where: { $0.toolCallID == parent }), let child = entries[entry].children.firstIndex(where: { $0.id == call }) else { return } + entries[entry].children[child].running = false + entries[entry].children[child].succeeded = event["ok"] as? Bool + entries[entry].children[child].summary = event["summary"] as? String ?? entries[entry].children[child].summary + entries[entry].children[child].durationMS = (event["millis"] as? NSNumber)?.intValue + case "compaction_started": status = "Compacting context…" + case "compaction_finished": status = (event["ok"] as? Bool == true) ? "Context compaction finished" : "Context compaction failed" + case "session_started": break + default: return + } + transcriptRevision += 1 + } + + private func appendChunk(role: TranscriptRole, text: String) { + guard !text.isEmpty else { return } + if let index = entries.indices.last, entries[index].role == role, entries[index].isStreaming { + entries[index].text = String((entries[index].text + text).suffix(256 * 1024)) + entries[index].formatted = nil + } else { + appendEntry(TranscriptEntry(role: role, text: String(text.suffix(256 * 1024)), isStreaming: true)) + if let id = entries.last?.id { streamingEntryIDs.insert(id) } + } + transcriptRevision += 1 + } + + private func appendEntry(_ entry: TranscriptEntry) { + entries.append(entry) + if entries.count > Self.maximumEntries { + let count = entries.count - Self.maximumEntries + let removed = entries.prefix(count).map(\.id) + entries.removeFirst(count) + streamingEntryIDs.subtract(removed) + } + transcriptRevision += 1 + } + + private func updateTool(_ update: [String: Any]) { + let id = update["toolCallId"] as? String + guard let index = entries.lastIndex(where: { $0.role == .tool && $0.toolCallID == id }) else { return } + if let title = update["title"] as? String { entries[index].title = title } + let detail = Self.toolDetail(update) + if !detail.isEmpty { + entries[index].text = detail + if detail.contains("is now running in the background") { entries[index].backgrounded = true } + } + if let state = update["status"] as? String { + let base = (entries[index].title ?? "Tool").components(separatedBy: " · " ).first ?? "Tool" + entries[index].title = "\(base) · \(state)" + entries[index].isStreaming = state == "in_progress" || state == "pending" + if !entries[index].isStreaming { streamingEntryIDs.remove(entries[index].id) } + } + transcriptRevision += 1; refreshActivity() + } + + private func upsertSingleton(role: TranscriptRole, title: String, text: String) { + if let index = entries.lastIndex(where: { $0.role == role }) { entries[index].text = text; entries[index].formatted = Self.markdown(text) } + else { appendEntry(TranscriptEntry(role: role, title: title, text: text, formatted: Self.markdown(text))) } + transcriptRevision += 1 + } + + private func finishStreamingEntries() { + var retained: Set = [] + for id in streamingEntryIDs { + guard let index = entries.firstIndex(where: { $0.id == id }) else { continue } + if entries[index].role == .tool, entries[index].isStreaming { retained.insert(id); continue } + entries[index].isStreaming = false + entries[index].formatted = Self.markdown(entries[index].text) + } + streamingEntryIDs = retained + transcriptRevision += 1 + } + + private func finalizeLastEntry() { + guard let index = entries.indices.last else { return } + entries[index].formatted = Self.markdown(entries[index].text) + } + + private func recordDiagnostic(_ line: String) { + diagnostics.append(line) + if diagnostics.count > 50 { diagnostics.removeFirst(diagnostics.count - 50) } + status = line + } + + private func refreshActivity() { + isRunning = foregroundRunning || !autonomousTurns.isEmpty + let toolRunning = entries.contains { $0.role == .tool && $0.isStreaming } + onActivityChanged?(isRunning || toolRunning) + } + + private func publishCurrentConfig(userSelected: Bool = false) { + let modelValue = configOptions.first(where: { $0.id == "model" })?.currentValue ?? "\(initialConversation.provider):\(initialConversation.model)" + let pieces = modelValue.split(separator: ":", maxSplits: 1).map(String.init) + let provider = pieces.count == 2 ? pieces[0] : initialConversation.provider + let model = pieces.count == 2 ? pieces[1] : modelValue + let effort = configOptions.first(where: { $0.id == "reasoning_effort" })?.currentValue ?? initialConversation.reasoningEffort + onConfigChanged?(provider, model, effort, userSelected) + } + + private func fail(_ error: Error) { + status = "Error: \(error.localizedDescription)" + appendEntry(TranscriptEntry(role: .error, text: error.localizedDescription, formatted: Self.markdown(error.localizedDescription))) + } + + static func parseConfigOptions(_ value: Any?) -> [ConfigOption] { + (value as? [[String: Any]] ?? []).compactMap { item in + guard let id = item["id"] as? String else { return nil } + let raw = item["options"] as? [[String: Any]] ?? [] + var groups: [ConfigGroup] = [] + var ungrouped: [ConfigChoice] = [] + for option in raw { + if let value = option["value"] as? String { ungrouped.append(ConfigChoice(value: value, name: option["name"] as? String ?? value)) } + else if let nested = option["options"] as? [[String: Any]] { + let choices = nested.compactMap { child -> ConfigChoice? in + guard let value = child["value"] as? String else { return nil } + return ConfigChoice(value: value, name: child["name"] as? String ?? value) + } + let groupID = option["group"] as? String ?? option["name"] as? String ?? UUID().uuidString + groups.append(ConfigGroup(id: groupID, name: option["name"] as? String ?? groupID, choices: choices)) + } + } + if !ungrouped.isEmpty { groups.insert(ConfigGroup(id: "default", name: "Options", choices: ungrouped), at: 0) } + return ConfigOption(id: id, name: item["name"] as? String ?? id, category: item["category"] as? String, currentValue: item["currentValue"] as? String ?? groups.first?.choices.first?.value ?? "", groups: groups) + } + } + + private static func attachment(_ url: URL) throws -> Attachment { + let canonical = url.standardizedFileURL + let values = try canonical.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]) + guard values.isRegularFile == true else { throw ACPClientError.attachment("\(url.lastPathComponent) is not a regular file") } + let size = Int64(values.fileSize ?? 0) + guard size <= maximumAttachmentBytes else { throw ACPClientError.attachment("\(url.lastPathComponent) exceeds the 10 MiB limit") } + let ext = canonical.pathExtension.lowercased() + let type: (AttachmentKind, String)? = switch ext { + case "png": (.image, "image/png") + case "jpg", "jpeg": (.image, "image/jpeg") + case "gif": (.image, "image/gif") + case "webp": (.image, "image/webp") + case "wav": (.audio, "audio/wav") + case "mp3": (.audio, "audio/mpeg") + default: nil + } + guard let type else { throw ACPClientError.attachment("Only PNG, JPEG, GIF, WebP, WAV, and MP3 attachments are supported") } + return Attachment(url: canonical, kind: type.0, mimeType: type.1, size: size) + } + + private static func parentCall(_ call: String) -> String? { call.range(of: ":compose:", options: .backwards).map { String(call[..<$0.lowerBound]) } } + + private static func contentText(_ value: Any?) -> String { + guard let content = value as? [String: Any] else { return describe(value) } + switch content["type"] as? String { + case "text": return content["text"] as? String ?? "" + case "image": + if let uri = content["uri"] as? String { return "[Image output](\(uri))" } + return "Image output (inline \(content["mimeType"] as? String ?? "image"))" + case "audio": return "Audio output (inline \(content["mimeType"] as? String ?? "audio"))" + case "resource_link": return content["uri"] as? String ?? "Resource" + default: return describe(content.filter { $0.key != "data" && $0.key != "blob" }) + } + } + + private static func toolDetail(_ update: [String: Any]) -> String { + if let content = update["content"] as? [[String: Any]] { + let text = content.compactMap { item -> String? in + if item["type"] as? String == "diff" { + let path = item["path"] as? String ?? "diff" + let lines = (item["newText"] as? String ?? "").split(separator: "\n", omittingEmptySubsequences: false).count + return "\(path) · \(lines) lines" + } + guard let body = item["content"] as? [String: Any] else { return nil } + return contentText(body) + }.joined(separator: "\n") + if !text.isEmpty { return bounded(text) } + } + let value = update["rawOutput"] ?? update["rawInput"] + return bounded(readableToolOutput(value) ?? describe(value)) + } + + private static func readableToolOutput(_ value: Any?) -> String? { + if let text = value as? String { + if let data = text.data(using: .utf8), + let decoded = try? JSONSerialization.jsonObject(with: data), + let readable = readableToolOutput(decoded) { return readable } + return text + } + guard let object = value as? [String: Any] else { return nil } + if let preview = object["preview"] as? String, !preview.isEmpty { return preview } + var sections: [String] = [] + if let stdout = object["stdout"] as? String, !stdout.isEmpty { sections.append(stdout) } + if let stderr = object["stderr"] as? String, !stderr.isEmpty { sections.append("stderr:\n\(stderr)") } + if !sections.isEmpty { return sections.joined(separator: "\n") } + if let command = object["command"] as? String { return command } + for key in ["output", "content", "message"] { + if let nested = object[key], let readable = readableToolOutput(nested), !readable.isEmpty { return readable } + } + return nil + } + + private static func bounded(_ text: String) -> String { + let lines = text.split(separator: "\n", omittingEmptySubsequences: false).prefix(5000).joined(separator: "\n") + return String(lines.prefix(128 * 1024)) + } + + private static func describe(_ value: Any?) -> String { + guard let value else { return "" } + if let text = value as? String { return text } + if JSONSerialization.isValidJSONObject(value), let data = try? JSONSerialization.data(withJSONObject: value, options: [.prettyPrinted, .sortedKeys]) { return String(decoding: data.prefix(128 * 1024), as: UTF8.self) } + return String(String(describing: value).prefix(128 * 1024)) + } + + private static func markdown(_ text: String) -> AttributedString { + (try? AttributedString(markdown: text, options: .init(interpretedSyntax: .full))) ?? AttributedString(text) + } +} diff --git a/macos/KitDesktop/Services/ACPClient.swift b/macos/KitDesktop/Services/ACPClient.swift new file mode 100644 index 0000000..37c4a8f --- /dev/null +++ b/macos/KitDesktop/Services/ACPClient.swift @@ -0,0 +1,539 @@ +import Darwin +import Foundation + +struct ACPLaunchOptions { + let root: String + let sessionID: String + let resume: Bool + let provider: String? + let model: String? + let reasoningEffort: String? +} + +final class ACPClient { + typealias Dictionary = [String: Any] + + struct LaunchOverride { + let executable: URL + var prefixArguments: [String] = [] + var environment: [String: String] = [:] + } + + var onUpdate: ((Dictionary) -> Void)? + var onRuntimeEvent: ((Dictionary) -> Void)? + var onDiagnostic: ((String) -> Void)? + var onExit: ((Int32) -> Void)? + + private struct Pending { + let method: String + let completion: (Result) -> Void + let timeout: DispatchWorkItem + } + + private let queue = DispatchQueue(label: "dev.kit.desktop.acp-transport", qos: .userInitiated) + private let inputPipe = Pipe() + private let outputPipe = Pipe() + private let errorPipe = Pipe() + private let launchOverride: LaunchOverride? + private let requestTimeout: TimeInterval + private let promptTimeout: TimeInterval + private var stdoutParser = JSONLineParser() + private var stderrParser = JSONLineParser(maximumLineBytes: 1024 * 1024) + private var nextID = 1 + private var pending: [Int: Pending] = [:] + private var sessionID: String? + private var closing = false + private var exited = false + private var processPID: pid_t? + private var processGroup: pid_t? + private var exitSource: DispatchSourceProcess? + private var exitStatus: Int32? + private var stdoutClosed = false + private var stderrClosed = false + private var pendingChunk: Dictionary? + private var closeCompletions: [() -> Void] = [] + private var chunkFlush: DispatchWorkItem? + + init(launchOverride: LaunchOverride? = nil, requestTimeout: TimeInterval = 30, promptTimeout: TimeInterval = 6 * 60 * 60) { + self.launchOverride = launchOverride + self.requestTimeout = requestTimeout + self.promptTimeout = promptTimeout + } + + func start(options: ACPLaunchOptions, loading: Bool, completion: @escaping (Result) -> Void) { + queue.async { self.startLocked(options: options, loading: loading, completion: completion) } + } + + func prompt(text: String, attachments: [Attachment], onSent: (() -> Void)? = nil, completion: @escaping (Result) -> Void) { + queue.async { + guard let sessionID = self.sessionID else { + self.complete(completion, with: .failure(ACPClientError.protocolError("ACP session is not ready"))) + return + } + do { + let blocks = try self.promptBlocks(text: text, attachments: attachments) + self.requestLocked(method: "session/prompt", params: ["sessionId": sessionID, "prompt": blocks], timeout: self.promptTimeout, onSent: onSent, completion: completion) + } catch { self.complete(completion, with: .failure(error)) } + } + } + + func setConfig(id: String, value: String, completion: @escaping (Result) -> Void) { + queue.async { + guard let sessionID = self.sessionID else { return } + self.requestLocked(method: "session/set_config_option", params: ["sessionId": sessionID, "configId": id, "value": value], completion: completion) + } + } + + func detachCompose(callID: String, completion: @escaping (Result) -> Void) { + privateRequest(method: "kit/compose/detach", callID: callID, completion: completion) + } + + func cancelBackground(callID: String, completion: @escaping (Result) -> Void) { + privateRequest(method: "kit/background/cancel", callID: callID, completion: completion) + } + + func cancel() { + queue.async { + guard let sessionID = self.sessionID else { return } + self.notifyLocked(method: "session/cancel", params: ["sessionId": sessionID]) + } + } + + func close(activeTurn: Bool, completion: (() -> Void)? = nil) { + queue.async { + if self.exited { if let completion { DispatchQueue.main.async(execute: completion) }; return } + if let completion { self.closeCompletions.append(completion) } + guard !self.closing else { return } + self.closing = true + if activeTurn, let sessionID = self.sessionID { + self.notifyLocked(method: "session/cancel", params: ["sessionId": sessionID]) + self.queue.asyncAfter(deadline: .now() + 3) { self.closeSessionLocked() } + } else { self.closeSessionLocked() } + } + } + + private func startLocked(options: ACPLaunchOptions, loading: Bool, completion: @escaping (Result) -> Void) { + do { + let launch = try launchOverride ?? Self.resolveLaunch() + let arguments = Self.commandArguments(prefix: launch.prefixArguments, options: options) + var environment = ProcessInfo.processInfo.environment + environment["KIT_RUNTIME_EVENTS"] = "1" + for (key, value) in launch.environment { environment[key] = value } + installReaders() + let pid = try spawn(executable: launch.executable.path, arguments: arguments, environment: environment, workingDirectory: options.root) + processPID = pid + processGroup = pid + let source = DispatchSource.makeProcessSource(identifier: pid, eventMask: .exit, queue: queue) + source.setEventHandler { [weak self] in self?.reapProcessLocked(pid) } + exitSource = source + source.resume() + } catch { + complete(completion, with: .failure(error)) + return + } + + requestLocked(method: "initialize", params: [ + "protocolVersion": 1, "clientCapabilities": [:], + "clientInfo": ["name": "Kit Desktop", "version": "0.1.87"], + ]) { result in + switch result { + case .failure(let error): + self.close(activeTurn: false) + self.complete(completion, with: .failure(error)) + case .success: + var params: Dictionary = ["cwd": options.root, "additionalDirectories": [], "mcpServers": []] + let method: String + if loading { method = "session/load"; params["sessionId"] = options.sessionID } + else { method = "session/new" } + self.queue.async { + self.requestLocked(method: method, params: params) { result in + self.queue.async { + if case .success(let payload) = result { self.sessionID = payload["sessionId"] as? String ?? options.sessionID } + else { self.closeSessionLocked() } + self.complete(completion, with: result) + } + } + } + } + } + } + + static func commandArguments(prefix: [String] = [], options: ACPLaunchOptions) -> [String] { + var arguments = prefix + ["serve", "--root", options.root] + if let model = options.model { arguments += ["--model", model] } + if let provider = options.provider { arguments += ["--provider", provider] } + if let effort = options.reasoningEffort { arguments += ["--reasoning-effort", effort] } + arguments += ["--session-id", options.sessionID] + if options.resume { arguments.append("--resume") } + return arguments + } + + private var isProcessRunning: Bool { processPID != nil && exitStatus == nil && !exited } + + private func installReaders() { + outputPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in + let data = handle.availableData + if data.isEmpty { handle.readabilityHandler = nil } + self?.queue.async { + guard let self else { return } + if data.isEmpty { self.stdoutEOFLocked() } else { self.consumeStdoutLocked(data) } + } + } + errorPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in + let data = handle.availableData + if data.isEmpty { handle.readabilityHandler = nil } + self?.queue.async { + guard let self else { return } + if data.isEmpty { self.stderrEOFLocked() } else { self.consumeStderrLocked(data) } + } + } + } + + private func consumeStdoutLocked(_ data: Data) { + do { + for line in try stdoutParser.append(data) { + for message in try RPCEnvelope.parseMany(line) { handleLocked(message) } + } + } catch { failTransportLocked(error) } + } + + private func consumeStderrLocked(_ data: Data) { + do { + for line in try stderrParser.append(data) { emitStderrLineLocked(line) } + } catch { DispatchQueue.main.async { self.onDiagnostic?(error.localizedDescription) } } + } + + private func emitStderrLineLocked(_ data: Data) { + guard var line = String(data: data, encoding: .utf8) else { return } + if line.hasPrefix("\u{1}kit-runtime\u{1}") { + line.removeFirst(13) + if let bytes = line.data(using: .utf8), let event = try? JSONSerialization.jsonObject(with: bytes) as? Dictionary { + DispatchQueue.main.async { self.onRuntimeEvent?(event) } + } + } else { + let capped = String(line.prefix(4096)) + DispatchQueue.main.async { self.onDiagnostic?(capped) } + } + } + + private func handleLocked(_ message: RPCEnvelope) { + if let method = message.method { + if method == "session/update", let update = message.params?["update"] as? Dictionary { enqueueUpdateLocked(update) } + else if method == "kit/turn/state", let params = message.params { + flushChunkLocked() + var update = params; update["sessionUpdate"] = "status"; emitUpdateLocked(update) + } else if let id = message.id { + sendLocked(["jsonrpc": "2.0", "id": id.jsonValue, "error": ["code": -32601, "message": "Client method not supported"]]) + } + return + } + if case .integer(let id) = message.id, let request = pending.removeValue(forKey: id) { + flushChunkLocked() + request.timeout.cancel() + if let error = message.error { complete(request.completion, with: .failure(ACPClientError.remote(error.code, error.message))) } + else { complete(request.completion, with: .success(message.result ?? [:])) } + } + } + + private func enqueueUpdateLocked(_ update: Dictionary) { + let kind = update["sessionUpdate"] as? String + let chunkKinds = ["user_message_chunk", "agent_message_chunk", "agent_thought_chunk"] + if let kind, chunkKinds.contains(kind), let content = update["content"] as? Dictionary, content["type"] as? String == "text", let text = content["text"] as? String { + if var pendingChunk, pendingChunk["sessionUpdate"] as? String == kind, var previous = pendingChunk["content"] as? Dictionary { + previous["text"] = String(((previous["text"] as? String ?? "") + text).suffix(256 * 1024)) + pendingChunk["content"] = previous + self.pendingChunk = pendingChunk + } else { flushChunkLocked(); pendingChunk = update } + if chunkFlush == nil { + let item = DispatchWorkItem { [weak self] in self?.flushChunkLocked() } + chunkFlush = item + queue.asyncAfter(deadline: .now() + 0.033, execute: item) + } + } else { flushChunkLocked(); emitUpdateLocked(update) } + } + + private func flushChunkLocked() { + chunkFlush?.cancel(); chunkFlush = nil + guard let update = pendingChunk else { return } + pendingChunk = nil + emitUpdateLocked(update) + } + + private func emitUpdateLocked(_ update: Dictionary) { + let capped = Self.capLargeFields(update) + DispatchQueue.main.async { self.onUpdate?(capped) } + } + + private static func capLargeFields(_ update: Dictionary) -> Dictionary { + var result = update + for key in ["rawInput", "rawOutput", "content"] { + guard let value = result[key], JSONSerialization.isValidJSONObject(value), let data = try? JSONSerialization.data(withJSONObject: value), data.count > 256 * 1024 else { continue } + let preview = String(decoding: data.prefix(16 * 1024), as: UTF8.self) + result[key] = ["truncated": true, "bytes": data.count, "preview": preview] + } + return result + } + + private func privateRequest(method: String, callID: String, completion: @escaping (Result) -> Void) { + queue.async { + guard let sessionID = self.sessionID else { return } + self.requestLocked(method: method, params: ["session_id": sessionID, "call_id": callID], completion: completion) + } + } + + private func requestLocked(method: String, params: Dictionary, timeout: TimeInterval? = nil, onSent: (() -> Void)? = nil, completion: @escaping (Result) -> Void) { + guard isProcessRunning, !exited else { complete(completion, with: .failure(ACPClientError.process("Kit process is not running"))); return } + let id = nextID; nextID += 1 + let work = DispatchWorkItem { [weak self] in + guard let self, let request = self.pending.removeValue(forKey: id) else { return } + self.complete(request.completion, with: .failure(ACPClientError.timeout(method))) + } + pending[id] = Pending(method: method, completion: completion, timeout: work) + do { try writeLocked(["jsonrpc": "2.0", "id": id, "method": method, "params": params]) } + catch { + pending.removeValue(forKey: id)?.timeout.cancel() + complete(completion, with: .failure(error)) + return + } + if let onSent { DispatchQueue.main.async(execute: onSent) } + queue.asyncAfter(deadline: .now() + (timeout ?? requestTimeout), execute: work) + } + + private func notifyLocked(method: String, params: Dictionary) { sendLocked(["jsonrpc": "2.0", "method": method, "params": params]) } + + private func sendLocked(_ object: Dictionary) { + do { try writeLocked(object) } catch { failTransportLocked(error) } + } + + private func writeLocked(_ object: Dictionary) throws { + guard isProcessRunning else { throw ACPClientError.process("Kit process is not running") } + var data = try JSONSerialization.data(withJSONObject: object) + data.append(0x0A) + try inputPipe.fileHandleForWriting.write(contentsOf: data) + } + + private func closeSessionLocked() { + guard isProcessRunning else { if let exitStatus { processDidExitLocked(exitStatus) }; return } + guard let sessionID else { terminateLocked(); return } + requestLocked(method: "session/close", params: ["sessionId": sessionID], timeout: 10) { _ in self.queue.async { self.terminateLocked() } } + } + + private func terminateLocked() { + try? inputPipe.fileHandleForWriting.close() + if let group = processGroup { _ = Darwin.kill(-group, SIGTERM) } + else if let pid = processPID, isProcessRunning { _ = Darwin.kill(pid, SIGTERM) } + queue.asyncAfter(deadline: .now() + 2) { + if let group = self.processGroup { + if Darwin.kill(-group, 0) == 0 { _ = Darwin.kill(-group, SIGKILL) } + } else if self.isProcessRunning, let pid = self.processPID { _ = Darwin.kill(pid, SIGKILL) } + } + } + + private func stdoutEOFLocked() { + guard !stdoutClosed else { return } + stdoutClosed = true + finishStdoutLocked() + try? outputPipe.fileHandleForReading.close() + if let status = exitStatus { finalizeExitLocked(status) } + else { queue.asyncAfter(deadline: .now() + 1) { if self.stdoutClosed && self.exitStatus == nil { self.failPendingLocked(ACPClientError.process("Kit closed ACP stdout")); self.terminateLocked() } } } + } + + private func stderrEOFLocked() { + guard !stderrClosed else { return } + stderrClosed = true + do { if let tail = try stderrParser.finish() { emitStderrLineLocked(tail) } } + catch { DispatchQueue.main.async { self.onDiagnostic?(error.localizedDescription) } } + try? errorPipe.fileHandleForReading.close() + if let status = exitStatus { finalizeExitLocked(status) } + } + + private func finishStdoutLocked() { + do { + if let tail = try stdoutParser.finish() { for message in try RPCEnvelope.parseMany(tail) { handleLocked(message) } } + } catch { failPendingLocked(error) } + } + + private func reapProcessLocked(_ pid: pid_t) { + var rawStatus: Int32 = 0 + guard waitpid(pid, &rawStatus, 0) == pid else { return } + let status = (rawStatus & 0x7f) == 0 ? (rawStatus >> 8) & 0xff : 128 + (rawStatus & 0x7f) + processDidExitLocked(status) + } + + private func processDidExitLocked(_ status: Int32) { + guard exitStatus == nil else { return } + exitStatus = status + if stdoutClosed && stderrClosed { finalizeExitLocked(status); return } + queue.asyncAfter(deadline: .now() + 5) { + guard !self.exited, self.exitStatus != nil else { return } + self.outputPipe.fileHandleForReading.readabilityHandler = nil + self.errorPipe.fileHandleForReading.readabilityHandler = nil + if !self.stdoutClosed { self.stdoutEOFLocked() } + if !self.stderrClosed { self.stderrEOFLocked() } + } + } + + private func finalizeExitLocked(_ status: Int32) { + guard !exited, stdoutClosed, stderrClosed else { return } + exited = true + exitSource?.cancel(); exitSource = nil + flushChunkLocked() + failPendingLocked(ACPClientError.process("Kit exited with status \(status)")) + let completions = closeCompletions; closeCompletions.removeAll() + DispatchQueue.main.async { + self.onExit?(status) + for completion in completions { completion() } + } + } + + private func failTransportLocked(_ error: Error) { + failPendingLocked(error) + terminateLocked() + } + + private func failPendingLocked(_ error: Error) { + let requests = Array(pending.values); pending.removeAll() + for request in requests { request.timeout.cancel(); complete(request.completion, with: .failure(error)) } + } + + private func complete(_ completion: @escaping (Result) -> Void, with result: Result) { + DispatchQueue.main.async { completion(result) } + } + + private func promptBlocks(text: String, attachments: [Attachment]) throws -> [Dictionary] { + guard attachments.count <= 8 else { throw ACPClientError.attachment("At most 8 attachments can be pending") } + var total: Int64 = 0 + var blocks: [Dictionary] = [] + var modelText = text + for (index, attachment) in attachments.enumerated() { + let data = try Data(contentsOf: attachment.url, options: .mappedIfSafe) + guard data.count <= 10 * 1024 * 1024 else { throw ACPClientError.attachment("\(attachment.url.lastPathComponent) exceeds the 10 MiB limit") } + total += Int64(data.count) + guard total <= 20 * 1024 * 1024 else { throw ACPClientError.attachment("Attachments exceed the 20 MiB total limit") } + let label = attachment.kind == .image ? "Image #\(index + 1)" : "Audio #\(index + 1)" + modelText += "\n[\(label)](\(attachment.url.absoluteString))" + var block: Dictionary = ["type": attachment.kind.rawValue, "data": data.base64EncodedString(), "mimeType": attachment.mimeType] + if attachment.kind == .image { block["uri"] = attachment.url.absoluteString } + blocks.append(block) + } + blocks.insert(["type": "text", "text": modelText], at: 0) + return blocks + } + + private func spawn(executable: String, arguments: [String], environment: [String: String], workingDirectory: String) throws -> pid_t { + var actions: posix_spawn_file_actions_t? + var attributes: posix_spawnattr_t? + guard posix_spawn_file_actions_init(&actions) == 0, posix_spawnattr_init(&attributes) == 0 else { + throw ACPClientError.process("Unable to initialize process launcher") + } + defer { posix_spawn_file_actions_destroy(&actions); posix_spawnattr_destroy(&attributes) } + + let stdinRead = inputPipe.fileHandleForReading.fileDescriptor + let stdinWrite = inputPipe.fileHandleForWriting.fileDescriptor + let stdoutRead = outputPipe.fileHandleForReading.fileDescriptor + let stdoutWrite = outputPipe.fileHandleForWriting.fileDescriptor + let stderrRead = errorPipe.fileHandleForReading.fileDescriptor + let stderrWrite = errorPipe.fileHandleForWriting.fileDescriptor + let actionResults = [ + posix_spawn_file_actions_adddup2(&actions, stdinRead, STDIN_FILENO), + posix_spawn_file_actions_adddup2(&actions, stdoutWrite, STDOUT_FILENO), + posix_spawn_file_actions_adddup2(&actions, stderrWrite, STDERR_FILENO), + posix_spawn_file_actions_addclose(&actions, stdinRead), + posix_spawn_file_actions_addclose(&actions, stdinWrite), + posix_spawn_file_actions_addclose(&actions, stdoutRead), + posix_spawn_file_actions_addclose(&actions, stdoutWrite), + posix_spawn_file_actions_addclose(&actions, stderrRead), + posix_spawn_file_actions_addclose(&actions, stderrWrite), + ] + guard actionResults.allSatisfy({ $0 == 0 }) else { throw ACPClientError.process("Unable to configure process pipes") } + let chdirResult = workingDirectory.withCString { directory in + if #available(macOS 26, *) { posix_spawn_file_actions_addchdir(&actions, directory) } + else { posix_spawn_file_actions_addchdir_np(&actions, directory) } + } + guard chdirResult == 0, + posix_spawnattr_setflags(&attributes, Int16(POSIX_SPAWN_SETPGROUP)) == 0, + posix_spawnattr_setpgroup(&attributes, 0) == 0 else { + throw ACPClientError.process("Unable to configure process group") + } + + var argv = ([executable] + arguments).map { strdup($0) } + [nil] + var envp = environment.map { strdup("\($0.key)=\($0.value)") } + [nil] + defer { argv.dropLast().forEach { free($0) }; envp.dropLast().forEach { free($0) } } + var pid: pid_t = 0 + let result = posix_spawn(&pid, executable, &actions, &attributes, &argv, &envp) + guard result == 0 else { throw ACPClientError.process("Unable to launch Kit: \(String(cString: strerror(result)))") } + try? inputPipe.fileHandleForReading.close() + try? outputPipe.fileHandleForWriting.close() + try? errorPipe.fileHandleForWriting.close() + return pid + } + + private static func resolveLaunch() throws -> LaunchOverride { + let fileManager = FileManager.default + let bundled = Bundle.main.bundleURL.appendingPathComponent("Contents/Helpers/kit") + if fileManager.isExecutableFile(atPath: bundled.path) { + return LaunchOverride(executable: bundled) + } + + let environment = ProcessInfo.processInfo.environment + if let override = environment["KIT_BINARY"], fileManager.isExecutableFile(atPath: override) { + return LaunchOverride(executable: URL(fileURLWithPath: override)) + } + + #if DEBUG + let source = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent().appendingPathComponent("target/debug/kit") + if fileManager.isExecutableFile(atPath: source.path) { return LaunchOverride(executable: source) } + #endif + + if let executable = installedExecutable(environment: environment, fileManager: fileManager) { + return LaunchOverride(executable: executable) + } + + // Finder-launched apps receive a minimal PATH. Ask the user's login shell for + // the same command lookup they get in Terminal, then launch the resolved file + // directly so every conversation uses an identical executable. + if let executable = resolveFromLoginShell(environment: environment) { + return LaunchOverride(executable: executable) + } + throw ACPClientError.missingBinary + } + + static func installedExecutable( + environment: [String: String], fileManager: FileManager = .default + ) -> URL? { + var searchDirectories = environment["PATH"]?.split(separator: ":").map(String.init) ?? [] + if let home = environment["HOME"] { searchDirectories.append(home + "/.cargo/bin") } + searchDirectories += ["/opt/homebrew/bin", "/usr/local/bin"] + for directory in searchDirectories { + let candidate = URL(fileURLWithPath: directory, isDirectory: true).appendingPathComponent("kit") + if fileManager.isExecutableFile(atPath: candidate.path) { return candidate } + } + return nil + } + + private static func resolveFromLoginShell(environment: [String: String]) -> URL? { + let shell = environment["SHELL"] ?? "/bin/zsh" + guard FileManager.default.isExecutableFile(atPath: shell) else { return nil } + let process = Process() + let output = Pipe() + process.executableURL = URL(fileURLWithPath: shell) + process.arguments = ["-lic", "command -v kit"] + process.environment = environment + process.standardOutput = output + process.standardError = FileHandle.nullDevice + do { + try process.run() + process.waitUntilExit() + guard process.terminationStatus == 0 else { return nil } + let data = output.fileHandleForReading.readDataToEndOfFile() + let lines = String(decoding: data, as: UTF8.self).split(whereSeparator: \.isNewline) + for line in lines.reversed() { + let path = String(line).trimmingCharacters(in: .whitespacesAndNewlines) + if path.hasPrefix("/"), FileManager.default.isExecutableFile(atPath: path) { + return URL(fileURLWithPath: path) + } + } + } catch {} + return nil + } +} diff --git a/macos/KitDesktop/Services/JSONLineParser.swift b/macos/KitDesktop/Services/JSONLineParser.swift new file mode 100644 index 0000000..2ed90b2 --- /dev/null +++ b/macos/KitDesktop/Services/JSONLineParser.swift @@ -0,0 +1,101 @@ +import Foundation + +struct JSONLineParser { + private var buffer = Data() + private let maximumLineBytes: Int + + init(maximumLineBytes: Int = 16 * 1024 * 1024) { self.maximumLineBytes = maximumLineBytes } + + mutating func append(_ data: Data) throws -> [Data] { + guard !data.isEmpty else { return [] } + buffer.append(data) + var lines: [Data] = [] + var start = buffer.startIndex + while start < buffer.endIndex, let newline = buffer[start...].firstIndex(of: 0x0A) { + let count = buffer.distance(from: start, to: newline) + guard count <= maximumLineBytes else { throw ACPClientError.protocolError("ACP line exceeds \(maximumLineBytes) bytes") } + var line = Data(buffer[start.. buffer.startIndex { buffer.removeSubrange(buffer.startIndex.. Data? { + defer { buffer.removeAll(keepingCapacity: false) } + guard !buffer.isEmpty else { return nil } + guard buffer.count <= maximumLineBytes else { throw ACPClientError.protocolError("ACP line exceeds \(maximumLineBytes) bytes") } + var line = buffer + if line.last == 0x0D { line.removeLast() } + return line.isEmpty ? nil : line + } +} + +enum RPCID: Equatable { + case integer(Int) + case string(String) + + var jsonValue: Any { + switch self { case .integer(let value): return value; case .string(let value): return value } + } +} + +struct RPCEnvelope { + let id: RPCID? + let method: String? + let params: [String: Any]? + let result: [String: Any]? + let error: RPCErrorPayload? + + static func parse(_ data: Data) throws -> RPCEnvelope { + let messages = try parseMany(data) + guard messages.count == 1, let message = messages.first else { throw ACPClientError.protocolError("Expected one JSON-RPC envelope") } + return message + } + + static func parseMany(_ data: Data) throws -> [RPCEnvelope] { + let value = try JSONSerialization.jsonObject(with: data) + if let object = value as? [String: Any] { return [try parse(object)] } + if let batch = value as? [[String: Any]], !batch.isEmpty { return try batch.map(parse) } + throw ACPClientError.protocolError("Invalid JSON-RPC envelope") + } + + private static func parse(_ object: [String: Any]) throws -> RPCEnvelope { + guard object["jsonrpc"] as? String == "2.0" else { throw ACPClientError.protocolError("Invalid JSON-RPC version") } + let id: RPCID? + if let value = object["id"] as? NSNumber { id = .integer(value.intValue) } + else if let value = object["id"] as? String { id = .string(value) } + else { id = nil } + let error: RPCErrorPayload? + if let payload = object["error"] as? [String: Any] { + error = RPCErrorPayload(code: (payload["code"] as? NSNumber)?.intValue ?? -1, message: payload["message"] as? String ?? "Unknown JSON-RPC error") + } else { error = nil } + return RPCEnvelope( + id: id, method: object["method"] as? String, params: object["params"] as? [String: Any], + result: object["result"] as? [String: Any], error: error + ) + } +} + +struct RPCErrorPayload: Error { let code: Int; let message: String } + +enum ACPClientError: LocalizedError { + case missingBinary + case process(String) + case protocolError(String) + case remote(Int, String) + case timeout(String) + case attachment(String) + + var errorDescription: String? { + switch self { + case .missingBinary: return "Kit CLI not found. Bundle Helpers/kit or set KIT_BINARY for Debug development." + case .process(let message), .protocolError(let message), .attachment(let message): return message + case .remote(let code, let message): return "ACP error \(code): \(message)" + case .timeout(let method): return "ACP request \(method) timed out" + } + } +} diff --git a/macos/KitDesktop/Services/PersistenceStore.swift b/macos/KitDesktop/Services/PersistenceStore.swift new file mode 100644 index 0000000..a3ec2a9 --- /dev/null +++ b/macos/KitDesktop/Services/PersistenceStore.swift @@ -0,0 +1,89 @@ +import Foundation + +enum PersistenceError: LocalizedError, Equatable { + case unsupportedSchema(Int) + case unreadableState(String) + + var errorDescription: String? { + switch self { + case .unsupportedSchema(let version): return "State schema \(version) is newer than this app supports." + case .unreadableState(let message): return "Could not recover saved state: \(message)" + } + } +} + +final class PersistenceStore { + let fileURL: URL + let backupURL: URL + private let queue = DispatchQueue(label: "dev.kit.desktop.persistence", qos: .utility) + + init(fileURL: URL? = nil) { + if let fileURL { self.fileURL = fileURL } + else { + let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + self.fileURL = base.appendingPathComponent("KitDesktop/state.json") + } + backupURL = self.fileURL.appendingPathExtension("backup") + } + + func load() throws -> PersistedAppState { + guard FileManager.default.fileExists(atPath: fileURL.path) else { return PersistedAppState() } + do { return try decode(Data(contentsOf: fileURL)) } + catch let error as PersistenceError { + if case .unsupportedSchema = error { throw error } + return try recover(after: error) + } catch { + return try recover(after: error) + } + } + + func save(_ state: PersistedAppState) throws { + let directory = fileURL.deletingLastPathComponent() + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let data = try Self.encoder.encode(state) + _ = try decode(data) + if FileManager.default.fileExists(atPath: fileURL.path), + let old = try? Data(contentsOf: fileURL), (try? decode(old)) != nil { + try? FileManager.default.removeItem(at: backupURL) + try FileManager.default.copyItem(at: fileURL, to: backupURL) + } + try data.write(to: fileURL, options: [.atomic, .completeFileProtectionUnlessOpen]) + } + + func saveAsync(_ state: PersistedAppState, completion: @escaping (Error?) -> Void) { + queue.async { + let error: Error? + do { try self.save(state); error = nil } catch let caught { error = caught } + DispatchQueue.main.async { completion(error) } + } + } + + func flush() { queue.sync {} } + + private func recover(after original: Error) throws -> PersistedAppState { + let quarantine = fileURL.deletingLastPathComponent().appendingPathComponent( + "state.corrupt-\(Int(Date().timeIntervalSince1970 * 1000)).json" + ) + try? FileManager.default.moveItem(at: fileURL, to: quarantine) + if FileManager.default.fileExists(atPath: backupURL.path) { + do { return try decode(Data(contentsOf: backupURL)) } + catch { throw PersistenceError.unreadableState("\(original.localizedDescription); backup: \(error.localizedDescription)") } + } + throw PersistenceError.unreadableState(original.localizedDescription) + } + + private func decode(_ data: Data) throws -> PersistedAppState { try Self.decoder.decode(PersistedAppState.self, from: data) } + + private static let encoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = [.sortedKeys] + return encoder + }() + + private static let decoder: JSONDecoder = { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder + }() +} diff --git a/macos/KitDesktop/Views/ContentView.swift b/macos/KitDesktop/Views/ContentView.swift new file mode 100644 index 0000000..c4ce0c0 --- /dev/null +++ b/macos/KitDesktop/Views/ContentView.swift @@ -0,0 +1,539 @@ +import SwiftUI +import UniformTypeIdentifiers + +struct ContentView: View { + @EnvironmentObject private var model: AppModel + + var body: some View { + NavigationSplitView { sidebar } detail: { detail } + .navigationSplitViewStyle(.balanced) + .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in + model.appBecameActive() + } + .alert("Persistence Error", isPresented: Binding( + get: { model.persistenceError != nil }, + set: { if !$0 { model.persistenceError = nil } } + )) { + Button("OK") { model.persistenceError = nil } + } message: { + Text(model.persistenceError ?? "") + } + } + + private var sidebar: some View { + VStack(spacing: 0) { + HStack(spacing: 10) { + Text("Kit").font(.title3.weight(.semibold)) + Spacer() + Button(action: chooseWorkspace) { Image(systemName: "folder.badge.plus") } + .buttonStyle(.plain).help("Add workspace") + Button(action: model.createConversation) { Image(systemName: "square.and.pencil") } + .buttonStyle(.plain).disabled(model.selectedWorkspaceID == nil).help("New conversation") + } + .font(.system(size: 15, weight: .medium)) + .padding(.horizontal, 16).frame(height: 50) + + if model.state.workspaces.isEmpty { + Spacer() + VStack(spacing: 12) { + Image(systemName: "folder.badge.plus").font(.system(size: 28)).foregroundStyle(.secondary) + Text("Add a workspace").font(.headline) + Text("Choose a project folder to start.").font(.callout).foregroundStyle(.secondary) + Button("Choose Folder", action: chooseWorkspace) + }.multilineTextAlignment(.center).padding(24) + Spacer() + } else { + workspacePicker.padding(.horizontal, 12).padding(.bottom, 10) + Divider().opacity(0.55) + ScrollView { + LazyVStack(alignment: .leading, spacing: 3) { + HStack { + Text("CONVERSATIONS").font(.caption2.weight(.semibold)).foregroundStyle(.tertiary) + Spacer() + }.padding(.horizontal, 10).padding(.top, 13).padding(.bottom, 5) + ForEach(model.workspaceConversations) { conversation in + Button { model.selectConversation(conversation.id) } label: { + ConversationRow( + conversation: conversation, + selected: conversation.id == model.selectedConversationID, + running: model.activity[conversation.id] == true + ) + }.buttonStyle(.plain) + } + if model.workspaceConversations.isEmpty { + VStack(spacing: 8) { + Text("No conversations yet").font(.callout).foregroundStyle(.secondary) + Button("Start a conversation", action: model.createConversation).buttonStyle(.link) + }.frame(maxWidth: .infinity).padding(.top, 36) + } + }.padding(.horizontal, 8).padding(.bottom, 12) + } + } + } + .background(Color(nsColor: .underPageBackgroundColor).opacity(0.72)) + .navigationSplitViewColumnWidth(min: 240, ideal: 280, max: 340) + } + + private var workspacePicker: some View { + Menu { + ForEach(model.state.workspaces) { workspace in + Button { model.selectWorkspace(workspace.id) } label: { + if workspace.id == model.selectedWorkspaceID { Label(workspace.name, systemImage: "checkmark") } + else { Text(workspace.name) } + } + } + Divider() + Button("Add Workspace…", action: chooseWorkspace) + } label: { + HStack(spacing: 8) { + Image(systemName: "folder").foregroundStyle(.secondary) + Text(model.selectedWorkspace?.name ?? "Workspace").fontWeight(.medium).lineLimit(1) + Spacer() + Image(systemName: "chevron.up.chevron.down").font(.caption2).foregroundStyle(.tertiary) + } + .padding(.horizontal, 10).frame(height: 34) + .background(.quaternary.opacity(0.65), in: RoundedRectangle(cornerRadius: 8)) + }.buttonStyle(.plain) + } + + @ViewBuilder + private var detail: some View { + if let controller = model.selectedController { + let title = model.state.conversations.first(where: { $0.id == model.selectedConversationID })?.title ?? "Conversation" + ConversationView(controller: controller, title: title) + } else { + VStack(spacing: 14) { + Image(systemName: "chevron.left.forwardslash.chevron.right") + .font(.system(size: 34, weight: .light)).foregroundStyle(.tertiary) + Text(model.selectedWorkspace == nil ? "Add a workspace to begin" : "What should we work on?") + .font(.title2.weight(.medium)) + if model.selectedWorkspace != nil { + Button("New Conversation", action: model.createConversation).buttonStyle(.borderedProminent) + } + }.frame(maxWidth: .infinity, maxHeight: .infinity).background(Color(nsColor: .windowBackgroundColor)) + } + } + + private func chooseWorkspace() { + let panel = NSOpenPanel() + panel.canChooseDirectories = true + panel.canChooseFiles = false + panel.allowsMultipleSelection = false + panel.prompt = "Add Workspace" + if panel.runModal() == .OK, let url = panel.url { model.addWorkspace(path: url.path) } + } +} + +private struct ConversationRow: View { + let conversation: Conversation + let selected: Bool + let running: Bool + + var body: some View { + HStack(alignment: .top, spacing: 10) { + statusMark.padding(.top, 5) + VStack(alignment: .leading, spacing: 5) { + Text(conversation.title).font(.callout.weight(conversation.unread ? .semibold : .regular)) + .foregroundStyle(.primary).lineLimit(2).multilineTextAlignment(.leading) + HStack(spacing: 6) { + Text(conversation.updatedAt, style: .relative) + if running { Text("Running").foregroundStyle(.green) } + else if conversation.awaitingUser { Text("Awaiting you").foregroundStyle(.orange) } + }.font(.caption2).foregroundStyle(.tertiary) + } + Spacer(minLength: 0) + } + .padding(.horizontal, 10).padding(.vertical, 9) + .frame(maxWidth: .infinity, alignment: .leading) + .background(selected ? Color.accentColor.opacity(0.13) : .clear, in: RoundedRectangle(cornerRadius: 9)) + .contentShape(Rectangle()) + } + + @ViewBuilder private var statusMark: some View { + if running { ProgressView().controlSize(.mini).frame(width: 9, height: 9) } + else if conversation.awaitingUser { Circle().fill(.orange).frame(width: 7, height: 7) } + else if conversation.unread { Circle().fill(.blue).frame(width: 7, height: 7) } + else { Circle().fill(.clear).frame(width: 7, height: 7) } + } +} + +private struct ConversationView: View { + @ObservedObject var controller: ConversationController + let title: String + @State private var choosingFiles = false + @State private var followTranscript = true + @State private var showDiagnostics = false + + var body: some View { + VStack(spacing: 0) { + conversationHeader + transcript + composer + } + .background(Color(nsColor: .windowBackgroundColor)) + .fileImporter(isPresented: $choosingFiles, allowedContentTypes: [.image, .audio], allowsMultipleSelection: true) { result in + if case .success(let urls) = result { controller.addAttachments(urls) } + } + } + + private var conversationHeader: some View { + HStack(spacing: 10) { + Text(title).font(.headline).lineLimit(1) + if controller.isRunning { + HStack(spacing: 5) { ProgressView().controlSize(.mini); Text("Working") } + .font(.caption).foregroundStyle(.secondary) + } + Spacer() + Button { followTranscript.toggle() } label: { + Image(systemName: followTranscript ? "arrow.down.to.line.compact" : "arrow.down") + }.buttonStyle(.plain).help(followTranscript ? "Following output" : "Resume following") + Button { controller.copyLastResponse() } label: { Image(systemName: "doc.on.doc") } + .buttonStyle(.plain).help("Copy last response") + if !controller.diagnostics.isEmpty { + Button { showDiagnostics.toggle() } label: { Image(systemName: "exclamationmark.bubble") } + .buttonStyle(.plain).help("Diagnostics").popover(isPresented: $showDiagnostics) { diagnosticsPopover } + } + } + .foregroundStyle(.secondary).padding(.horizontal, 20).frame(height: 50) + .overlay(alignment: .bottom) { Divider().opacity(0.55) } + } + + private var diagnosticsPopover: some View { + ScrollView { + Text(controller.diagnostics.joined(separator: "\n")) + .font(.system(.caption, design: .monospaced)).textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading).padding() + }.frame(width: 560, height: 260) + } + + private var transcript: some View { + ScrollViewReader { proxy in + ScrollView { + if controller.entries.isEmpty { + VStack(spacing: 14) { + Image(systemName: "sparkles").font(.system(size: 30, weight: .light)).foregroundStyle(.tertiary) + Text("What should we work on?").font(.title2.weight(.medium)) + }.frame(maxWidth: .infinity).padding(.top, 150) + } else { + LazyVStack(alignment: .leading, spacing: 22) { + ForEach(controller.entries) { entry in + TranscriptRow( + entry: entry, + detach: { if let id = entry.toolCallID { controller.detachCompose(callID: id) } }, + cancelBackground: { if let id = entry.toolCallID { controller.cancelBackground(callID: id) } } + ).id(entry.id) + } + Color.clear.frame(height: 1).id("bottom") + } + .frame(maxWidth: 820, alignment: .leading) + .padding(.horizontal, 30).padding(.top, 28).padding(.bottom, 24) + .frame(maxWidth: .infinity) + } + } + .onChange(of: controller.transcriptRevision) { _, _ in + if followTranscript { withAnimation(.easeOut(duration: 0.18)) { proxy.scrollTo("bottom", anchor: .bottom) } } + } + } + } + + private var commandSuggestions: [AdvertisedCommand] { + guard controller.draft.hasPrefix("/") else { return [] } + let query = String(controller.draft.dropFirst()).lowercased() + return controller.advertisedCommands.filter { query.isEmpty || $0.name.lowercased().hasPrefix(query) } + } + + private var composer: some View { + VStack(spacing: 0) { + if !commandSuggestions.isEmpty { commandBar } + VStack(spacing: 10) { + attachmentBar + TextField("Message Kit", text: $controller.draft, axis: .vertical) + .textFieldStyle(.plain).font(.body).lineLimit(1...8) + .padding(.horizontal, 3).padding(.vertical, 5) + HStack(spacing: 10) { + Button { choosingFiles = true } label: { Image(systemName: "plus") } + .buttonStyle(.plain).font(.system(size: 15, weight: .medium)) + .disabled(controller.attachments.count >= ConversationController.maximumAttachmentCount) + .help("Attach image or audio") + modelControl + effortControl + contextControl + Spacer(minLength: 6) + Text(controller.status).font(.caption).foregroundStyle(.tertiary).lineLimit(1) + if controller.canCancel { + Button(action: controller.cancel) { Image(systemName: "stop.fill") } + .buttonStyle(.bordered).controlSize(.small).help("Stop") + } + Button(action: controller.send) { + Image(systemName: "arrow.up").font(.system(size: 13, weight: .bold)).frame(width: 24, height: 24) + } + .buttonStyle(.borderedProminent).buttonBorderShape(.circle).controlSize(.small) + .keyboardShortcut(.return, modifiers: .command) + .disabled(!canSend) + } + } + .padding(.horizontal, 14).padding(.vertical, 12) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16)) + .overlay { RoundedRectangle(cornerRadius: 16).stroke(.separator.opacity(0.72)) } + .shadow(color: .black.opacity(0.08), radius: 16, y: 5) + .frame(maxWidth: 820) + .padding(.horizontal, 26).padding(.bottom, 18) + }.frame(maxWidth: .infinity) + } + + private var commandBar: some View { + HStack(spacing: 14) { + ForEach(commandSuggestions) { command in + Button { controller.draft = "/\(command.name) " } label: { + VStack(alignment: .leading, spacing: 2) { + Text("/\(command.name)").fontWeight(.medium) + Text(command.description).foregroundStyle(.secondary) + } + }.buttonStyle(.plain) + } + Spacer() + }.font(.caption).padding(10).frame(maxWidth: 790).background(.regularMaterial, in: RoundedRectangle(cornerRadius: 10)).padding(.bottom, 7) + } + + @ViewBuilder private var attachmentBar: some View { + if !controller.attachments.isEmpty { + ScrollView(.horizontal) { + HStack(spacing: 7) { + ForEach(controller.attachments) { attachment in + HStack(spacing: 5) { + Image(systemName: attachment.kind == .image ? "photo" : "waveform") + Text(attachment.url.lastPathComponent).lineLimit(1) + Button { controller.removeAttachment(attachment.id) } label: { Image(systemName: "xmark.circle.fill") }.buttonStyle(.plain) + }.font(.caption).padding(.horizontal, 8).padding(.vertical, 5).background(.quaternary, in: Capsule()) + } + } + } + } + } + + @ViewBuilder private var modelControl: some View { + if let option = controller.configOptions.first(where: { $0.id == "model" }) { + Menu { + ForEach(option.groups) { group in + Section(group.name) { + ForEach(group.choices) { choice in + Button { controller.choose(option, value: choice.value) } label: { + if choice.value == option.currentValue { Label(choice.name, systemImage: "checkmark") } + else { Text(choice.name) } + } + } + } + } + } label: { + HStack(spacing: 4) { Image(systemName: "cpu"); Text(selectedModelLabel(option)).lineLimit(1) } + }.menuStyle(.borderlessButton).fixedSize().font(.caption) + } + } + + @ViewBuilder private var effortControl: some View { + if let option = controller.configOptions.first(where: { $0.id == "reasoning_effort" }) { + HStack(spacing: 6) { + Image(systemName: "brain.head.profile").foregroundStyle(.secondary) + Slider(value: effortBinding(option), in: 0...2, step: 1).frame(width: 72).controlSize(.mini) + Text(effortLabel(option)).font(.caption).foregroundStyle(.secondary).fixedSize() + }.help("Reasoning effort") + } + } + + @ViewBuilder private var contextControl: some View { + if let used = controller.contextUsed, let size = controller.contextSize, size > 0 { + let percentage = min(100, Int((Double(used) / Double(size) * 100).rounded())) + HStack(spacing: 5) { + ProgressView(value: Double(used), total: Double(size)).controlSize(.mini).frame(width: 38) + Text("\(percentage)%").font(.caption2).monospacedDigit().foregroundStyle(.tertiary) + }.help("Context: \(used.formatted()) of \(size.formatted()) tokens") + } + } + + private var canSend: Bool { + controller.acceptsInput && (!controller.draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !controller.attachments.isEmpty) + } + + private func selectedName(_ option: ConfigOption) -> String { + option.choices.first(where: { $0.value == option.currentValue })?.name ?? option.currentValue.split(separator: ":").last.map(String.init) ?? option.currentValue + } + + private func effortBinding(_ option: ConfigOption) -> Binding { + Binding(get: { + switch option.currentValue { case "low": 0; case "high": 2; default: 1 } + }, set: { value in + let effort = ["low", "medium", "high"][max(0, min(2, Int(value.rounded())))] + if effort != option.currentValue { controller.choose(option, value: effort) } + }) + } + + private func selectedModelLabel(_ option: ConfigOption) -> String { + let pieces = option.currentValue.split(separator: ":", maxSplits: 1).map(String.init) + guard pieces.count == 2 else { return selectedName(option) } + return "\(pieces[0]) / \(selectedName(option))" + } + + private func effortLabel(_ option: ConfigOption) -> String { + if option.currentValue == "default" { return "Effort medium (default)" } + return "Effort \(option.currentValue.lowercased())" + } +} + +private struct TranscriptRow: View { + let entry: TranscriptEntry + let detach: () -> Void + let cancelBackground: () -> Void + + var body: some View { + Group { + switch entry.role { + case .user: userMessage + case .assistant: assistantMessage + case .thought: ThoughtCard(entry: entry) + case .tool: ToolCard(entry: entry, detach: detach, cancelBackground: cancelBackground) + case .plan: planMessage + case .status: statusMessage + case .error: errorMessage + case .usage: EmptyView() + } + }.frame(maxWidth: .infinity, alignment: entry.role == .user ? .trailing : .leading) + } + + private var userMessage: some View { + MessageText(entry: entry) + .padding(.horizontal, 14).padding(.vertical, 10) + .background(Color.accentColor.opacity(0.13), in: RoundedRectangle(cornerRadius: 15)) + .frame(maxWidth: 570, alignment: .trailing) + } + + private var assistantMessage: some View { + MessageText(entry: entry).frame(maxWidth: 760, alignment: .leading) + } + + private var planMessage: some View { + VStack(alignment: .leading, spacing: 8) { + Label("Plan", systemImage: "checklist").font(.callout.weight(.semibold)).foregroundStyle(.secondary) + MarkdownView(source: entry.text) + }.padding(14).background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 12)) + } + + private var statusMessage: some View { + Label(entry.text, systemImage: "info.circle").font(.caption).foregroundStyle(.tertiary).padding(.vertical, 2) + } + + private var errorMessage: some View { + HStack(alignment: .top, spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.red) + Text(entry.text).textSelection(.enabled) + }.font(.callout).padding(12).background(.red.opacity(0.08), in: RoundedRectangle(cornerRadius: 10)) + } +} + +private struct MessageText: View { + let entry: TranscriptEntry + var body: some View { + if entry.isStreaming { Text(entry.text).textSelection(.enabled).lineSpacing(3) } + else { MarkdownView(source: entry.text) } + } +} + +private struct ThoughtCard: View { + let entry: TranscriptEntry + @State private var expanded = false + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Button { withAnimation(.easeInOut(duration: 0.16)) { expanded.toggle() } } label: { + HStack(spacing: 9) { + Image(systemName: "brain.head.profile").foregroundStyle(.purple) + Text(entry.isStreaming ? "Thinking…" : "Reasoning").font(.callout.weight(.medium)) + Spacer() + Image(systemName: expanded ? "chevron.up" : "chevron.down").font(.caption2).foregroundStyle(.tertiary) + }.contentShape(Rectangle()) + }.buttonStyle(.plain).padding(.horizontal, 12).padding(.vertical, 10) + if expanded { + Divider().opacity(0.55) + MessageText(entry: entry).foregroundStyle(.secondary).padding(12) + } + } + .background(.quaternary.opacity(0.42), in: RoundedRectangle(cornerRadius: 11)) + .overlay { RoundedRectangle(cornerRadius: 11).stroke(.separator.opacity(0.55)) } + } +} + +private struct ToolCard: View { + let entry: TranscriptEntry + let detach: () -> Void + let cancelBackground: () -> Void + @State private var expanded = false + + private var pieces: [String] { (entry.title ?? "Tool").components(separatedBy: " · ") } + private var title: String { pieces.first ?? "Tool" } + private var status: String { pieces.count > 1 ? pieces.last! : (entry.isStreaming ? "in progress" : "completed") } + private var succeeded: Bool { ["completed", "success"].contains(status.lowercased()) } + private var failed: Bool { ["failed", "error"].contains(status.lowercased()) } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Button { withAnimation(.easeInOut(duration: 0.16)) { expanded.toggle() } } label: { + HStack(spacing: 10) { + Image(systemName: statusIcon).foregroundStyle(statusColor).frame(width: 17) + Text(title).font(.callout.weight(.semibold)).lineLimit(1) + Text(status.replacingOccurrences(of: "_", with: " ")) + .font(.caption).foregroundStyle(.secondary) + Spacer() + if !entry.children.isEmpty { Text("\(entry.children.count) calls").font(.caption2).foregroundStyle(.tertiary) } + Image(systemName: expanded ? "chevron.up" : "chevron.down").font(.caption2).foregroundStyle(.tertiary) + }.contentShape(Rectangle()) + }.buttonStyle(.plain).padding(.horizontal, 14).padding(.vertical, 12) + + if expanded { + Divider().opacity(0.55) + VStack(alignment: .leading, spacing: 10) { + ForEach(entry.children) { child in + HStack(spacing: 8) { + Image(systemName: childIcon(child)) + .foregroundStyle(childColor(child)) + Text(child.tool).font(.caption.weight(.semibold)) + Text(child.summary).font(.caption).foregroundStyle(.secondary).lineLimit(2) + Spacer() + if let duration = child.durationMS { Text(durationText(duration)).font(.caption2).foregroundStyle(.tertiary) } + } + } + if !entry.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + ScrollView([.horizontal, .vertical]) { + Text(entry.text).font(.system(.caption, design: .monospaced)).textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading).padding(10) + }.frame(maxHeight: 260).background(Color(nsColor: .textBackgroundColor).opacity(0.6), in: RoundedRectangle(cornerRadius: 8)) + } + if entry.isStreaming, title.lowercased().hasPrefix("compose") { + Button(entry.backgrounded ? "Cancel background call" : "Run in background", action: entry.backgrounded ? cancelBackground : detach) + .buttonStyle(.bordered).controlSize(.small) + } + }.padding(12) + } + } + .background(.quaternary.opacity(0.46), in: RoundedRectangle(cornerRadius: 12)) + .overlay { RoundedRectangle(cornerRadius: 12).stroke(.separator.opacity(0.62)) } + } + + private func childIcon(_ child: RuntimeChild) -> String { + if child.running { return "circle.dotted" } + return child.succeeded == true ? "checkmark.circle.fill" : "xmark.circle.fill" + } + private func childColor(_ child: RuntimeChild) -> Color { + if child.running { return .secondary } + return child.succeeded == true ? .green : .red + } + + private var statusIcon: String { + if entry.isStreaming { return "circle.dotted" } + if succeeded { return "checkmark.circle.fill" } + if failed { return "xmark.circle.fill" } + return "wrench.and.screwdriver.fill" + } + private var statusColor: Color { entry.isStreaming ? .secondary : (failed ? .red : (succeeded ? .green : .secondary)) } + private func durationText(_ milliseconds: Int) -> String { + milliseconds < 1_000 ? "\(milliseconds) ms" : String(format: "%.1f s", Double(milliseconds) / 1_000) + } +} diff --git a/macos/KitDesktop/Views/MarkdownView.swift b/macos/KitDesktop/Views/MarkdownView.swift new file mode 100644 index 0000000..19b5f02 --- /dev/null +++ b/macos/KitDesktop/Views/MarkdownView.swift @@ -0,0 +1,194 @@ +import AppKit +import SwiftUI + +struct MarkdownDocument: Equatable { + enum Block: Equatable { + case heading(level: Int, text: String) + case paragraph(String) + case unordered([String]) + case ordered([String]) + case quote(String) + case code(language: String?, text: String) + case rule + } + + let blocks: [Block] + + init(_ source: String) { + let lines = source.replacingOccurrences(of: "\r\n", with: "\n").components(separatedBy: "\n") + var result: [Block] = [] + var index = 0 + while index < lines.count { + let line = lines[index] + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty { index += 1; continue } + + if trimmed.hasPrefix("```") { + let language = String(trimmed.dropFirst(3)).trimmingCharacters(in: .whitespaces) + index += 1 + var code: [String] = [] + while index < lines.count, !lines[index].trimmingCharacters(in: .whitespaces).hasPrefix("```") { + code.append(lines[index]); index += 1 + } + if index < lines.count { index += 1 } + result.append(.code(language: language.isEmpty ? nil : language, text: code.joined(separator: "\n"))) + continue + } + + if let heading = Self.heading(line) { result.append(heading); index += 1; continue } + if Self.isRule(trimmed) { result.append(.rule); index += 1; continue } + + if Self.unorderedItem(line) != nil { + var items: [String] = [] + while index < lines.count, let item = Self.unorderedItem(lines[index]) { items.append(item); index += 1 } + result.append(.unordered(items)); continue + } + if Self.orderedItem(line) != nil { + var items: [String] = [] + while index < lines.count, let item = Self.orderedItem(lines[index]) { items.append(item); index += 1 } + result.append(.ordered(items)); continue + } + if trimmed.hasPrefix(">") { + var quote: [String] = [] + while index < lines.count { + let candidate = lines[index].trimmingCharacters(in: .whitespaces) + guard candidate.hasPrefix(">") else { break } + quote.append(String(candidate.dropFirst()).trimmingCharacters(in: .whitespaces)) + index += 1 + } + result.append(.quote(quote.joined(separator: "\n"))); continue + } + + var paragraph: [String] = [line] + index += 1 + while index < lines.count { + let candidate = lines[index] + let candidateTrimmed = candidate.trimmingCharacters(in: .whitespaces) + if candidateTrimmed.isEmpty || candidateTrimmed.hasPrefix("```") || Self.heading(candidate) != nil || Self.isRule(candidateTrimmed) || Self.unorderedItem(candidate) != nil || Self.orderedItem(candidate) != nil || candidateTrimmed.hasPrefix(">") { break } + paragraph.append(candidate); index += 1 + } + result.append(.paragraph(paragraph.joined(separator: "\n"))) + } + blocks = result + } + + private static func heading(_ line: String) -> Block? { + let trimmed = line.trimmingCharacters(in: .whitespaces) + let count = trimmed.prefix(while: { $0 == "#" }).count + guard (1...6).contains(count), trimmed.dropFirst(count).first == " " else { return nil } + return .heading(level: count, text: String(trimmed.dropFirst(count + 1))) + } + + private static func isRule(_ line: String) -> Bool { + let compact = line.replacingOccurrences(of: " ", with: "") + guard compact.count >= 3, let first = compact.first, ["-", "*", "_"].contains(first) else { return false } + return compact.allSatisfy { $0 == first } + } + + private static func unorderedItem(_ line: String) -> String? { + let trimmed = line.trimmingCharacters(in: .whitespaces) + for marker in ["- ", "* ", "+ "] where trimmed.hasPrefix(marker) { return String(trimmed.dropFirst(2)) } + return nil + } + + private static func orderedItem(_ line: String) -> String? { + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard let dot = trimmed.firstIndex(of: "."), dot != trimmed.startIndex else { return nil } + let number = trimmed[.. some View { + switch block { + case .heading(let level, let text): + InlineMarkdown(text).font(headingFont(level)).fontWeight(.semibold).padding(.top, level <= 2 ? 5 : 2) + case .paragraph(let text): + InlineMarkdown(text).font(.body).lineSpacing(3) + case .unordered(let items): + VStack(alignment: .leading, spacing: 7) { + ForEach(Array(items.enumerated()), id: \.offset) { _, item in + HStack(alignment: .firstTextBaseline, spacing: 9) { + Text("•").foregroundStyle(.secondary); InlineMarkdown(item).frame(maxWidth: .infinity, alignment: .leading) + } + } + }.padding(.leading, 5) + case .ordered(let items): + VStack(alignment: .leading, spacing: 7) { + ForEach(Array(items.enumerated()), id: \.offset) { index, item in + HStack(alignment: .firstTextBaseline, spacing: 9) { + Text("\(index + 1).").monospacedDigit().foregroundStyle(.secondary).frame(minWidth: 18, alignment: .trailing) + InlineMarkdown(item).frame(maxWidth: .infinity, alignment: .leading) + } + } + } + case .quote(let text): + HStack(alignment: .top, spacing: 11) { + Capsule().fill(.tertiary).frame(width: 3) + InlineMarkdown(text).foregroundStyle(.secondary) + }.padding(.vertical, 2) + case .code(let language, let text): + CodeBlockView(language: language, code: text) + case .rule: + Divider().padding(.vertical, 4) + } + } + + private func headingFont(_ level: Int) -> Font { + switch level { case 1: .title2; case 2: .title3; case 3: .headline; default: .body } + } +} + +private struct InlineMarkdown: View { + let value: AttributedString + + init(_ source: String) { + value = (try? AttributedString(markdown: source, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace))) ?? AttributedString(source) + } + + var body: some View { Text(value).textSelection(.enabled) } +} + +private struct CodeBlockView: View { + let language: String? + let code: String + @State private var copied = false + + var body: some View { + VStack(spacing: 0) { + HStack { + Text(language?.uppercased() ?? "CODE").font(.caption2.weight(.medium)).foregroundStyle(.secondary) + Spacer() + Button { + NSPasteboard.general.clearContents(); NSPasteboard.general.setString(code, forType: .string) + copied = true + DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { copied = false } + } label: { Label(copied ? "Copied" : "Copy", systemImage: copied ? "checkmark" : "doc.on.doc") } + .buttonStyle(.plain).font(.caption).foregroundStyle(.secondary) + }.padding(.horizontal, 12).padding(.vertical, 8) + Divider() + ScrollView(.horizontal) { + Text(code).font(.system(.callout, design: .monospaced)).textSelection(.enabled) + .padding(12).frame(maxWidth: .infinity, alignment: .leading) + } + } + .background(Color(nsColor: .textBackgroundColor).opacity(0.55), in: RoundedRectangle(cornerRadius: 10)) + .overlay { RoundedRectangle(cornerRadius: 10).stroke(.separator.opacity(0.7)) } + } +} diff --git a/macos/KitDesktopTests/ACPProcessTests.swift b/macos/KitDesktopTests/ACPProcessTests.swift new file mode 100644 index 0000000..155b429 --- /dev/null +++ b/macos/KitDesktopTests/ACPProcessTests.swift @@ -0,0 +1,163 @@ +import Darwin +import Foundation +import XCTest +@testable import Kit + +final class ACPProcessTests: XCTestCase { + func testFixtureStreamsRichUpdatesEncodesMediaTimesOutAndCloses() throws { + let client = makeClient(promptTimeout: 1.0) + let ready = expectation(description: "ready") + let runtime = expectation(description: "runtime events"); runtime.expectedFulfillmentCount = 2 + let diagnostic = expectation(description: "diagnostic") + let exited = expectation(description: "exited") + var updates: [[String: Any]] = [] + var runtimeEvents: [[String: Any]] = [] + var diagnostics: [String] = [] + client.onRuntimeEvent = { runtimeEvents.append($0); runtime.fulfill() } + client.onDiagnostic = { diagnostics.append($0); if $0 == "mock diagnostic" { diagnostic.fulfill() } } + client.onUpdate = { updates.append($0) } + client.onExit = { _ in exited.fulfill() } + client.start(options: options(sessionID: "desktop-new", resume: false), loading: false) { result in + if case .failure(let error) = result { XCTFail(error.localizedDescription) } + ready.fulfill() + } + wait(for: [ready], timeout: 3) + + let rich = expectation(description: "rich prompt") + client.prompt(text: "MOCK_RICH_OUTPUT", attachments: []) { result in + if case .failure(let error) = result { XCTFail(error.localizedDescription) } + rich.fulfill() + } + wait(for: [rich, runtime, diagnostic], timeout: 3) + let kinds = updates.compactMap { $0["sessionUpdate"] as? String } + XCTAssertTrue(kinds.contains("agent_thought_chunk")) + XCTAssertTrue(kinds.contains("tool_call")) + XCTAssertTrue(kinds.contains("usage_update")) + XCTAssertEqual(runtimeEvents.compactMap { $0["event"] as? String }, ["child_started", "child_finished"]) + XCTAssertTrue(diagnostics.contains("mock diagnostic")) + + let directory = temporaryDirectory() + let imageURL = directory.appendingPathComponent("image.png") + try Data([0x89, 0x50, 0x4e, 0x47]).write(to: imageURL) + let media = Attachment(url: imageURL, kind: .image, mimeType: "image/png", size: 4) + let mediaDone = expectation(description: "media prompt") + client.prompt(text: "MOCK_MEDIA", attachments: [media]) { result in + if case .failure(let error) = result { XCTFail(error.localizedDescription) } + mediaDone.fulfill() + } + wait(for: [mediaDone], timeout: 3) + let texts = updates.compactMap { ($0["content"] as? [String: Any])?["text"] as? String } + XCTAssertTrue(texts.contains("text,image")) + + let timedOut = expectation(description: "prompt timeout") + client.prompt(text: "MOCK_HANG", attachments: []) { result in + guard case .failure(let error) = result else { XCTFail("expected timeout"); timedOut.fulfill(); return } + XCTAssertTrue(error.localizedDescription.contains("timed out")) + timedOut.fulfill() + } + wait(for: [timedOut], timeout: 2) + client.close(activeTurn: false) + wait(for: [exited], timeout: 3) + } + + func testLoadReplayUpdatesArriveBeforeLoadCompletion() { + let client = makeClient(promptTimeout: 2) + let ready = expectation(description: "loaded") + let exited = expectation(description: "exited") + var order: [String] = [] + client.onUpdate = { update in + if let kind = update["sessionUpdate"] as? String, kind == "user_message_chunk" || kind == "agent_message_chunk" { order.append(kind) } + } + client.onExit = { _ in exited.fulfill() } + client.start(options: options(sessionID: "loaded-session", resume: true), loading: true) { result in + if case .failure(let error) = result { XCTFail(error.localizedDescription) } + order.append("ready") + ready.fulfill() + } + wait(for: [ready], timeout: 3) + XCTAssertEqual(Array(order.prefix(3)), ["user_message_chunk", "agent_message_chunk", "ready"]) + client.close(activeTurn: false) + wait(for: [exited], timeout: 3) + } + + @MainActor + func testAttachmentReadFailureKeepsDraftAndPendingFiles() { + let client = makeClient(promptTimeout: 2) + let conversation = Conversation(workspaceID: UUID()) + let controller = ConversationController(conversation: conversation, workspacePath: repositoryRoot.path, client: client) + let ready = expectation(description: "ready") + controller.onSessionReady = { _, _ in ready.fulfill() } + controller.start() + wait(for: [ready], timeout: 3) + + let missing = Attachment(url: temporaryDirectory().appendingPathComponent("missing.png"), kind: .image, mimeType: "image/png", size: 1) + controller.draft = "keep this" + controller.attachments = [missing] + controller.send() + let failed = expectation(description: "attachment failure") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + XCTAssertEqual(controller.draft, "keep this") + XCTAssertEqual(controller.attachments, [missing]) + XCTAssertTrue(controller.status.hasPrefix("Error:")) + failed.fulfill() + } + wait(for: [failed], timeout: 2) + let closed = expectation(description: "closed") + controller.close { closed.fulfill() } + wait(for: [closed], timeout: 3) + } + + func testDrainsUnterminatedFinalResponseBeforeReportingExit() { + let client = makeClient(promptTimeout: 2, environment: ["MOCK_EXIT_TAIL": "1"]) + let ready = expectation(description: "final response handled") + let exited = expectation(description: "exited") + client.onExit = { status in XCTAssertEqual(status, 0); exited.fulfill() } + client.start(options: options(sessionID: "tail", resume: false), loading: false) { result in + if case .failure(let error) = result { XCTFail(error.localizedDescription) } + ready.fulfill() + } + wait(for: [ready, exited], timeout: 3) + } + + func testCloseKillsProcessGroupDescendantThatIgnoresTerm() throws { + let pidFile = temporaryDirectory().appendingPathComponent("child.pid") + let client = makeClient(promptTimeout: 2, environment: ["MOCK_CHILD_PID_FILE": pidFile.path]) + let ready = expectation(description: "ready") + let exited = expectation(description: "exited") + client.onExit = { _ in exited.fulfill() } + client.start(options: options(sessionID: "group", resume: false), loading: false) { result in + if case .failure(let error) = result { XCTFail(error.localizedDescription) } + ready.fulfill() + } + wait(for: [ready], timeout: 3) + let childPID = try XCTUnwrap(Int32(String(contentsOf: pidFile, encoding: .utf8))) + addTeardownBlock { _ = Darwin.kill(childPID, SIGKILL) } + XCTAssertEqual(Darwin.kill(childPID, 0), 0) + client.close(activeTurn: false) + wait(for: [exited], timeout: 4) + for _ in 0..<20 where Darwin.kill(childPID, 0) == 0 { usleep(50_000) } + XCTAssertEqual(Darwin.kill(childPID, 0), -1) + XCTAssertEqual(errno, ESRCH) + } + + private func makeClient(promptTimeout: TimeInterval, environment: [String: String] = [:]) -> ACPClient { + let fixture = repositoryRoot.appendingPathComponent("fixtures/mock-acp.py") + let launch = ACPClient.LaunchOverride(executable: URL(fileURLWithPath: "/usr/bin/python3"), prefixArguments: [fixture.path, "--models"], environment: environment) + return ACPClient(launchOverride: launch, requestTimeout: 2, promptTimeout: promptTimeout) + } + + private func options(sessionID: String, resume: Bool) -> ACPLaunchOptions { + ACPLaunchOptions(root: repositoryRoot.path, sessionID: sessionID, resume: resume, provider: "openai-subscription", model: "gpt-5.4", reasoningEffort: "default") + } + + private var repositoryRoot: URL { + URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() + } + + private func temporaryDirectory() -> URL { + let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + addTeardownBlock { try? FileManager.default.removeItem(at: url) } + return url + } +} diff --git a/macos/KitDesktopTests/ACPProtocolTests.swift b/macos/KitDesktopTests/ACPProtocolTests.swift new file mode 100644 index 0000000..87d1a19 --- /dev/null +++ b/macos/KitDesktopTests/ACPProtocolTests.swift @@ -0,0 +1,86 @@ +import Foundation +import XCTest +@testable import Kit + +final class ACPProtocolTests: XCTestCase { + func testLaunchArgumentsInheritKitConfigUntilUserOverrides() { + let inherited = ACPLaunchOptions( + root: "/tmp/project", sessionID: "session", resume: false, + provider: nil, model: nil, reasoningEffort: nil + ) + let inheritedArguments = ACPClient.commandArguments(options: inherited) + XCTAssertFalse(inheritedArguments.contains("--provider")) + XCTAssertFalse(inheritedArguments.contains("--model")) + XCTAssertFalse(inheritedArguments.contains("--reasoning-effort")) + + let explicit = ACPLaunchOptions( + root: "/tmp/project", sessionID: "session", resume: true, + provider: "openai-subscription", model: "gpt-5.6-sol", reasoningEffort: "high" + ) + XCTAssertEqual(ACPClient.commandArguments(options: explicit), [ + "serve", "--root", "/tmp/project", + "--model", "gpt-5.6-sol", + "--provider", "openai-subscription", + "--reasoning-effort", "high", + "--session-id", "session", "--resume", + ]) + } + + func testFindsCargoInstalledKitWhenGUIPathIsMinimal() throws { + let home = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + let cargoBin = home.appendingPathComponent(".cargo/bin", isDirectory: true) + let executable = cargoBin.appendingPathComponent("kit") + try FileManager.default.createDirectory(at: cargoBin, withIntermediateDirectories: true) + try Data("#!/bin/sh\n".utf8).write(to: executable) + XCTAssertEqual(chmod(executable.path, 0o755), 0) + defer { try? FileManager.default.removeItem(at: home) } + + let resolved = ACPClient.installedExecutable(environment: [ + "HOME": home.path, "PATH": "/usr/bin:/bin", + ]) + + XCTAssertEqual(resolved?.standardizedFileURL, executable.standardizedFileURL) + } + + func testParsesSessionUpdateNotification() throws { + let data = Data(#"{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"s1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"hello"}}}}"#.utf8) + let envelope = try RPCEnvelope.parse(data) + XCTAssertEqual(envelope.method, "session/update") + let update = try XCTUnwrap(envelope.params?["update"] as? [String: Any]) + XCTAssertEqual(update["sessionUpdate"] as? String, "agent_message_chunk") + } + + @MainActor + func testParsesGroupedKitConfigOptions() throws { + let options: [[String: Any]] = [[ + "id": "model", "name": "Model", "currentValue": "openrouter:one", + "options": [["group": "openrouter", "name": "OpenRouter", "options": [["value": "openrouter:one", "name": "One"]]]] + ]] + + let parsed = ConversationController.parseConfigOptions(options) + + XCTAssertEqual(parsed.first?.currentValue, "openrouter:one") + XCTAssertEqual(parsed.first?.groups.first?.name, "OpenRouter") + XCTAssertEqual(parsed.first?.choices.first?.value, "openrouter:one") + } + + func testParsesJSONRPCBatch() throws { + let data = Data(#"[{"jsonrpc":"2.0","id":1,"result":{}},{"jsonrpc":"2.0","id":2,"result":{}}]"#.utf8) + XCTAssertEqual(try RPCEnvelope.parseMany(data).map(\.id), [.integer(1), .integer(2)]) + } + + func testPreservesStringRequestIDs() throws { + let request = try RPCEnvelope.parse(Data(#"{"jsonrpc":"2.0","id":"agent-1","method":"client/example","params":{}}"#.utf8)) + XCTAssertEqual(request.id, .string("agent-1")) + } + + func testParsesResponseAndRemoteError() throws { + let response = try RPCEnvelope.parse(Data(#"{"jsonrpc":"2.0","id":7,"result":{"sessionId":"abc"}}"#.utf8)) + XCTAssertEqual(response.id, .integer(7)) + XCTAssertEqual(response.result?["sessionId"] as? String, "abc") + + let failure = try RPCEnvelope.parse(Data(#"{"jsonrpc":"2.0","id":8,"error":{"code":-32602,"message":"bad option"}}"#.utf8)) + XCTAssertEqual(failure.error?.code, -32602) + XCTAssertEqual(failure.error?.message, "bad option") + } +} diff --git a/macos/KitDesktopTests/AppModelTests.swift b/macos/KitDesktopTests/AppModelTests.swift new file mode 100644 index 0000000..9973545 --- /dev/null +++ b/macos/KitDesktopTests/AppModelTests.swift @@ -0,0 +1,40 @@ +import XCTest +@testable import Kit + +final class AppModelTests: XCTestCase { + @MainActor + func testAttentionOnlyAppliesToUnfocusedConversations() { + let focused = AppModel.attentionState(reason: "end_turn", isFocused: true) + XCTAssertFalse(focused.awaitingUser) + XCTAssertFalse(focused.unread) + + let hidden = AppModel.attentionState(reason: "end_turn", isFocused: false) + XCTAssertTrue(hidden.awaitingUser) + XCTAssertTrue(hidden.unread) + + let failed = AppModel.attentionState(reason: "error", isFocused: false) + XCTAssertFalse(failed.awaitingUser) + XCTAssertTrue(failed.unread) + } + + @MainActor + func testFocusingConversationClearsPersistedAttention() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let store = PersistenceStore(fileURL: directory.appendingPathComponent("state.json")) + let workspace = Workspace(name: "Project", path: directory.path) + let conversation = Conversation( + workspaceID: workspace.id, unread: true, awaitingUser: true + ) + try store.save(PersistedAppState(workspaces: [workspace], conversations: [conversation])) + let model = AppModel(store: store) + model.selectedConversationID = conversation.id + + model.appBecameActive() + + let updated = try XCTUnwrap(model.state.conversations.first) + XCTAssertFalse(updated.unread) + XCTAssertFalse(updated.awaitingUser) + store.flush() + } +} diff --git a/macos/KitDesktopTests/JSONLineParserTests.swift b/macos/KitDesktopTests/JSONLineParserTests.swift new file mode 100644 index 0000000..429d36e --- /dev/null +++ b/macos/KitDesktopTests/JSONLineParserTests.swift @@ -0,0 +1,29 @@ +import Foundation +import XCTest +@testable import Kit + +final class JSONLineParserTests: XCTestCase { + func testEmitsOnlyCompleteFragmentedLines() throws { + var parser = JSONLineParser() + XCTAssertTrue(try parser.append(Data(#"{"jsonrpc":"2.0","id":1"#.utf8)).isEmpty) + + let lines = try parser.append(Data("}\r\n{\"jsonrpc\":\"2.0\",\"id\":2}\n".utf8)) + + XCTAssertEqual(lines.count, 2) + XCTAssertEqual(try RPCEnvelope.parse(lines[0]).id, .integer(1)) + XCTAssertEqual(try RPCEnvelope.parse(lines[1]).id, .integer(2)) + } + + func testRejectsAnUnboundedPartialLine() throws { + var parser = JSONLineParser(maximumLineBytes: 8) + XCTAssertThrowsError(try parser.append(Data(repeating: 0x61, count: 9))) + } + + func testPreservesTrailingLineUntilFinish() throws { + var parser = JSONLineParser() + XCTAssertTrue(try parser.append(Data(#"{"jsonrpc":"2.0","method":"session/update"}"#.utf8)).isEmpty) + let tail = try XCTUnwrap(parser.finish()) + XCTAssertEqual(try RPCEnvelope.parse(tail).method, "session/update") + XCTAssertNil(try parser.finish()) + } +} diff --git a/macos/KitDesktopTests/MarkdownDocumentTests.swift b/macos/KitDesktopTests/MarkdownDocumentTests.swift new file mode 100644 index 0000000..35f4d2e --- /dev/null +++ b/macos/KitDesktopTests/MarkdownDocumentTests.swift @@ -0,0 +1,33 @@ +import XCTest +@testable import Kit + +final class MarkdownDocumentTests: XCTestCase { + func testParsesBlockMarkdownWithoutFlatteningStructure() { + let source = """ + # Markdown + + - Item 1 + - Item 2 + + **Bold** and *italic*. + + ```javascript + console.log(\"hello\"); + ``` + """ + + XCTAssertEqual(MarkdownDocument(source).blocks, [ + .heading(level: 1, text: "Markdown"), + .unordered(["Item 1", "Item 2"]), + .paragraph("**Bold** and *italic*."), + .code(language: "javascript", text: "console.log(\"hello\");"), + ]) + } + + func testPreservesParagraphAndQuoteLineBreaks() { + XCTAssertEqual(MarkdownDocument("first\nsecond\n\n> quote\n> next").blocks, [ + .paragraph("first\nsecond"), + .quote("quote\nnext"), + ]) + } +} diff --git a/macos/KitDesktopTests/PersistenceStoreTests.swift b/macos/KitDesktopTests/PersistenceStoreTests.swift new file mode 100644 index 0000000..e3bb71f --- /dev/null +++ b/macos/KitDesktopTests/PersistenceStoreTests.swift @@ -0,0 +1,90 @@ +import Foundation +import XCTest +@testable import Kit + +final class PersistenceStoreTests: XCTestCase { + func testMissingFileLoadsEmptyState() throws { + let url = temporaryDirectory().appendingPathComponent("state.json") + XCTAssertEqual(try PersistenceStore(fileURL: url).load(), PersistedAppState()) + } + + func testRoundTripsWorkspaceScopedConversations() throws { + let url = temporaryDirectory().appendingPathComponent("nested/state.json") + let workspace = Workspace(id: UUID(), name: "Kit", path: "/tmp/kit", createdAt: Date(timeIntervalSince1970: 10)) + let conversation = Conversation( + id: UUID(), workspaceID: workspace.id, title: "Continue me", sessionID: "session-42", + createdAt: Date(timeIntervalSince1970: 20), updatedAt: Date(timeIntervalSince1970: 30), + unread: true, awaitingUser: true, usesConfiguredDefaults: false + ) + let expected = PersistedAppState(workspaces: [workspace], conversations: [conversation]) + let store = PersistenceStore(fileURL: url) + + try store.save(expected) + + XCTAssertEqual(try store.load(), expected) + } + + func testMigratesVersionlessV1StateWithConversationDefaults() throws { + let directory = temporaryDirectory() + let url = directory.appendingPathComponent("state.json") + let workspaceID = UUID() + let conversationID = UUID() + let json = """ + {"workspaces":[{"id":"\(workspaceID)","name":"Kit","path":"/tmp/kit","createdAt":"1970-01-01T00:00:10Z"}],"conversations":[{"id":"\(conversationID)","workspaceID":"\(workspaceID)","title":"Old","createdAt":"1970-01-01T00:00:20Z","updatedAt":"1970-01-01T00:00:30Z"}]} + """ + try Data(json.utf8).write(to: url) + + let loaded = try PersistenceStore(fileURL: url).load() + + XCTAssertEqual(loaded.schemaVersion, PersistedAppState.currentSchemaVersion) + XCTAssertEqual(loaded.conversations.first?.provider, "openai-subscription") + XCTAssertEqual(loaded.conversations.first?.reasoningEffort, "default") + XCTAssertEqual(loaded.conversations.first?.usesConfiguredDefaults, true) + } + + func testMigratesV2HardCodedFallbacksToConfiguredDefaults() throws { + let url = temporaryDirectory().appendingPathComponent("state.json") + let workspaceID = UUID() + let conversationID = UUID() + let json = """ + {"schemaVersion":2,"workspaces":[{"id":"\(workspaceID)","name":"Kit","path":"/tmp/kit","createdAt":"1970-01-01T00:00:10Z"}],"conversations":[{"id":"\(conversationID)","workspaceID":"\(workspaceID)","title":"Old","createdAt":"1970-01-01T00:00:20Z","updatedAt":"1970-01-01T00:00:30Z","provider":"openai-subscription","model":"gpt-5.4","reasoningEffort":"default"}]} + """ + try Data(json.utf8).write(to: url) + + let loaded = try PersistenceStore(fileURL: url).load() + + XCTAssertEqual(loaded.schemaVersion, 3) + XCTAssertEqual(loaded.conversations.first?.usesConfiguredDefaults, true) + } + + func testQuarantinesCorruptPrimaryAndRecoversBackup() throws { + let directory = temporaryDirectory() + let url = directory.appendingPathComponent("state.json") + let store = PersistenceStore(fileURL: url) + let first = PersistedAppState(workspaces: [Workspace(name: "First", path: "/first")]) + let second = PersistedAppState(workspaces: [Workspace(name: "Second", path: "/second")]) + try store.save(first) + try store.save(second) + try Data("not-json".utf8).write(to: url) + + let recovered = try store.load() + + XCTAssertEqual(recovered.workspaces.first?.name, "First") + let files = try FileManager.default.contentsOfDirectory(atPath: directory.path) + XCTAssertTrue(files.contains { $0.hasPrefix("state.corrupt-") }) + } + + func testNewerSchemaIsPreservedAndRejected() throws { + let url = temporaryDirectory().appendingPathComponent("state.json") + try Data("{\"schemaVersion\":99,\"workspaces\":[],\"conversations\":[]}".utf8).write(to: url) + XCTAssertThrowsError(try PersistenceStore(fileURL: url).load()) + XCTAssertTrue(FileManager.default.fileExists(atPath: url.path)) + } + + private func temporaryDirectory() -> URL { + let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + addTeardownBlock { try? FileManager.default.removeItem(at: url) } + return url + } +} diff --git a/macos/README.md b/macos/README.md new file mode 100644 index 0000000..5a82040 --- /dev/null +++ b/macos/README.md @@ -0,0 +1,78 @@ +# Kit Desktop for macOS (v0) + +A dependency-free SwiftUI client for Kit's native ACP server. It targets macOS 14, disables App Sandbox so Kit and its tools can access workspaces, and launches one retained Kit helper process per opened conversation. Switching conversations never cancels their work. + +## Prerequisites + +- macOS 14 or newer +- Xcode 16 or newer +- [XcodeGen](https://github.com/yonaskolb/XcodeGen): `brew install xcodegen` +- Rust toolchain required by the repository + +## Generate, test, build, and run + +Run from the repository root: + +```sh +cargo build --locked --bin kit +cd macos +xcodegen generate + +xcodebuild \ + -project KitDesktop.xcodeproj \ + -scheme KitDesktop \ + -configuration Debug \ + -destination 'platform=macOS' \ + -derivedDataPath .build \ + CODE_SIGNING_ALLOWED=NO \ + KIT_BINARY=../target/debug/kit \ + test + +xcodebuild \ + -project KitDesktop.xcodeproj \ + -scheme KitDesktop \ + -configuration Debug \ + -destination 'platform=macOS' \ + -derivedDataPath .build \ + CODE_SIGNING_ALLOWED=NO \ + KIT_BINARY=../target/debug/kit \ + build + +open .build/Build/Products/Debug/Kit.app +``` + +A Release build must contain an optimized helper and fails rather than creating an incomplete app: + +```sh +cd .. +cargo build --locked --release --bin kit +cd macos +xcodebuild \ + -project KitDesktop.xcodeproj \ + -scheme KitDesktop \ + -configuration Release \ + -destination 'platform=macOS' \ + -derivedDataPath .build-release \ + KIT_BINARY=../target/release/kit \ + build +``` + +The build phase copies the selected executable to `Kit.app/Contents/Helpers/kit`, rejects Debug helpers and architecture mismatches in Release, and removes stale output first. When no bundled helper exists, runtime lookup honors `KIT_BINARY`, the inherited `PATH`, common Homebrew locations, `~/.cargo/bin`, and the user's login-shell `PATH`. Debug builds also check the repository's `target/debug/kit`. Release builds still require a bundled optimized helper so archives are self-contained. + +## Architecture and protocol + +- `AppModel` owns a controller dictionary keyed by conversation ID. Each controller and helper remain alive across sidebar/workspace navigation, so multiple conversations can run independently and update unread/awaiting-user state. +- `ACPClient` launches the TUI-style `kit serve` helper with root, model, provider, reasoning effort, durable session ID, resume state, and `KIT_RUNTIME_EVENTS=1`. It initializes ACP and uses `session/new` or `session/load`. +- A serial transport queue owns newline framing, JSON decoding, pending requests, timeouts, ordered writes, stderr event parsing, and process shutdown. UI callbacks are delivered on the main actor. +- Streaming text is coalesced to about 30 updates per second. Transcript count, stream text, parser lines, diagnostics, and raw tool output are bounded. Markdown is parsed only after a stream completes. +- The UI supports grouped provider/model and reasoning config, context usage, `/compact` discovery, copy-last-response, diagnostics, nested runtime graph rows, foreground cancel, compose detach, and detached-call cancellation. +- Attachments match the TUI: PNG/JPEG/GIF/WebP and WAV/MP3 are base64 ACP image/audio blocks, limited to 8 files, 10 MiB each, and 20 MiB total. +- `PersistenceStore` uses schema version 3, migrates the versionless v1 state, atomically saves on a utility queue, keeps a validated backup, quarantines corrupt primary files, and refuses unsupported newer schemas. State lives at `~/Library/Application Support/KitDesktop/state.json`; Kit remains the transcript source of truth. +- Process tests launch the shared `fixtures/mock-acp.py`, cover rich streaming/media/request timeout/close, and verify `session/load` replay arrives before the load response. + +## Current limitations + +- Boolean or free-form ACP configuration editors are not included; current Kit exposes grouped select options for model and reasoning effort. +- Conversation rename/delete/search and transcript export are not part of v0. +- Unknown agent-to-client request methods receive JSON-RPC `Method not found`; current Kit does not require desktop filesystem or terminal request handlers. +- Notifications require macOS permission and are posted for completed turns while the app is inactive. diff --git a/macos/project.yml b/macos/project.yml new file mode 100644 index 0000000..a1983b3 --- /dev/null +++ b/macos/project.yml @@ -0,0 +1,90 @@ +name: KitDesktop +options: + bundleIdPrefix: dev.kit + deploymentTarget: + macOS: "14.0" +settings: + base: + SWIFT_VERSION: "5.0" + MACOSX_DEPLOYMENT_TARGET: "14.0" + CODE_SIGN_STYLE: Automatic + ENABLE_USER_SCRIPT_SANDBOXING: NO +targets: + KitDesktop: + type: application + platform: macOS + sources: + - KitDesktop + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: dev.kit.desktop + PRODUCT_NAME: Kit + GENERATE_INFOPLIST_FILE: YES + INFOPLIST_KEY_CFBundleDisplayName: Kit + INFOPLIST_KEY_LSApplicationCategoryType: public.app-category.developer-tools + ENABLE_APP_SANDBOX: NO + postBuildScripts: + - name: Copy Kit CLI + basedOnDependencyAnalysis: true + inputFiles: + - $(SRCROOT)/../Cargo.toml + - $(SRCROOT)/../Cargo.lock + outputFiles: + - $(TARGET_BUILD_DIR)/$(CONTENTS_FOLDER_PATH)/Helpers/kit + script: | + set -eu + helper_dir="${TARGET_BUILD_DIR}/${CONTENTS_FOLDER_PATH}/Helpers" + destination="${helper_dir}/kit" + rm -f "${destination}" + mkdir -p "${helper_dir}" + source_binary="${KIT_BINARY:-}" + if [ -z "${source_binary}" ]; then + if [ "${CONFIGURATION}" = "Release" ]; then + source_binary="${SRCROOT}/../target/release/kit" + else + source_binary="${SRCROOT}/../target/debug/kit" + fi + fi + if [ ! -x "${source_binary}" ]; then + if [ "${CONFIGURATION}" = "Release" ]; then + echo "error: Release requires an optimized Kit helper. Run: cargo build --locked --release --bin kit" >&2 + exit 1 + fi + echo "warning: Debug Kit helper unavailable; KIT_BINARY/source-tree/PATH runtime fallback remains enabled." + exit 0 + fi + if [ "${CONFIGURATION}" = "Release" ] && printf '%s' "${source_binary}" | grep -q '/target/debug/'; then + echo "error: refusing to package a Debug Kit helper in Release" >&2 + exit 1 + fi + helper_arches="$(lipo -archs "${source_binary}")" + for required_arch in ${ARCHS}; do + if ! printf ' %s ' "${helper_arches}" | grep -q " ${required_arch} "; then + echo "error: Kit helper architectures (${helper_arches}) do not include ${required_arch}" >&2 + exit 1 + fi + done + cp "${source_binary}" "${destination}" + chmod +x "${destination}" + KitDesktopTests: + type: bundle.unit-test + platform: macOS + sources: + - KitDesktopTests + dependencies: + - target: KitDesktop + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: dev.kit.desktop.tests + GENERATE_INFOPLIST_FILE: YES + TEST_HOST: "$(BUILT_PRODUCTS_DIR)/Kit.app/Contents/MacOS/Kit" + BUNDLE_LOADER: "$(TEST_HOST)" +schemes: + KitDesktop: + build: + targets: + KitDesktop: all + KitDesktopTests: [test] + test: + targets: + - KitDesktopTests