feat: add stdio MCP transport support

- Add McpTransport enum with Http and Stdio variants
- Update McpServerConfig to support both transport types
- Implement stdio process spawning and JSON-RPC communication
- Store tool descriptors with full metadata (name, description, schema)
- Maintain backwards compatibility with existing HTTP MCPs

This enables using community MCP servers that use stdio transport
(which is the standard for most MCP servers).
This commit is contained in:
Thomas Marchand
2025-12-17 08:36:06 +00:00
parent 8e201b3e44
commit d7a6b846c3
27 changed files with 4711 additions and 79 deletions

View File

@@ -0,0 +1,452 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
02DB7F25245D03FF72DD8E2E /* ControlView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A84519FDE8FC75084938B292 /* ControlView.swift */; };
0620B298DEF91DFCAE050DAC /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 66A48A20D2178760301256C9 /* Assets.xcassets */; };
29372E691F6A5C5D2CCD9331 /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A09A33A3A1A99446C8A88DC /* HistoryView.swift */; };
4B50B97618C0CC469FF64592 /* Theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504A1222CE8971417834D229 /* Theme.swift */; };
4D0CF2666262F45370D000DF /* TerminalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AC6317C4EAD4DB9A8190209 /* TerminalView.swift */; };
5152C5313CD5AC01276D0AE6 /* FileEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA70A2A73D3A386EAFD69FC4 /* FileEntry.swift */; };
6865FE997D3E1D91D411F6BC /* LoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B9834D4EE32058824F9DF00 /* LoadingView.swift */; };
6B87076797C9DFA01E24CC76 /* FilesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5908645A518F48B501390AB8 /* FilesView.swift */; };
999ACAA94B0BD81A05288092 /* GlassCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB5A4720378F06807FDE73E1 /* GlassCard.swift */; };
9BC40E40E1B5622B24328AEB /* Mission.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4AB47CF121ABA1946A4D879 /* Mission.swift */; };
AA02567226057045DDD61CB1 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99B57FC3136B64DC87413CA6 /* ContentView.swift */; };
CA70EC5A864C3D007D42E781 /* ChatMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CB591B632D3EF26AB217976 /* ChatMessage.swift */; };
D64972881E36894950658708 /* APIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBC90C32FEF604E025FFBF78 /* APIService.swift */; };
DA4634D7424AF3FC985987E7 /* GlassButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5267DE67017A858357F68424 /* GlassButton.swift */; };
FA7E68F22D16E1AC0B5F5E22 /* StatusBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD6FB2E54DC07BE7A1EB08F8 /* StatusBadge.swift */; };
FF9C447978711CBA9185B8B0 /* OpenAgentDashboardApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 139C740B7D55C13F3B167EF3 /* OpenAgentDashboardApp.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
0AC6317C4EAD4DB9A8190209 /* TerminalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalView.swift; sourceTree = "<group>"; };
139C740B7D55C13F3B167EF3 /* OpenAgentDashboardApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenAgentDashboardApp.swift; sourceTree = "<group>"; };
2B9834D4EE32058824F9DF00 /* LoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingView.swift; sourceTree = "<group>"; };
3CB591B632D3EF26AB217976 /* ChatMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessage.swift; sourceTree = "<group>"; };
43A2EBAE84C0FFDCA5E1D66E /* OpenAgentDashboard.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = OpenAgentDashboard.entitlements; sourceTree = "<group>"; };
4D3D6B3EA3B04DE534F9709A /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
504A1222CE8971417834D229 /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = "<group>"; };
5267DE67017A858357F68424 /* GlassButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlassButton.swift; sourceTree = "<group>"; };
5908645A518F48B501390AB8 /* FilesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilesView.swift; sourceTree = "<group>"; };
5A09A33A3A1A99446C8A88DC /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = "<group>"; };
66A48A20D2178760301256C9 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
99B57FC3136B64DC87413CA6 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
A84519FDE8FC75084938B292 /* ControlView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlView.swift; sourceTree = "<group>"; };
BA70A2A73D3A386EAFD69FC4 /* FileEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileEntry.swift; sourceTree = "<group>"; };
CBC90C32FEF604E025FFBF78 /* APIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIService.swift; sourceTree = "<group>"; };
CD6FB2E54DC07BE7A1EB08F8 /* StatusBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusBadge.swift; sourceTree = "<group>"; };
D4AB47CF121ABA1946A4D879 /* Mission.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Mission.swift; sourceTree = "<group>"; };
EB5A4720378F06807FDE73E1 /* GlassCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlassCard.swift; sourceTree = "<group>"; };
F51395D8FB559D3C79AAA0A4 /* OpenAgentDashboard.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = OpenAgentDashboard.app; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXGroup section */
0C1185300420EEF31B892A3A /* Files */ = {
isa = PBXGroup;
children = (
5908645A518F48B501390AB8 /* FilesView.swift */,
);
path = Files;
sourceTree = "<group>";
};
0D9369EE2F3374EAA1EF332E /* Terminal */ = {
isa = PBXGroup;
children = (
0AC6317C4EAD4DB9A8190209 /* TerminalView.swift */,
);
path = Terminal;
sourceTree = "<group>";
};
1B2400F48D7D400DF42A11F0 /* DesignSystem */ = {
isa = PBXGroup;
children = (
504A1222CE8971417834D229 /* Theme.swift */,
);
path = DesignSystem;
sourceTree = "<group>";
};
279F9B8FE97DDCBF76C2E85E /* Products */ = {
isa = PBXGroup;
children = (
F51395D8FB559D3C79AAA0A4 /* OpenAgentDashboard.app */,
);
name = Products;
sourceTree = "<group>";
};
2EF415E84544334B25BD8E26 /* Components */ = {
isa = PBXGroup;
children = (
5267DE67017A858357F68424 /* GlassButton.swift */,
EB5A4720378F06807FDE73E1 /* GlassCard.swift */,
2B9834D4EE32058824F9DF00 /* LoadingView.swift */,
CD6FB2E54DC07BE7A1EB08F8 /* StatusBadge.swift */,
);
path = Components;
sourceTree = "<group>";
};
5A40B212F0D2055C1C499FCC /* History */ = {
isa = PBXGroup;
children = (
5A09A33A3A1A99446C8A88DC /* HistoryView.swift */,
);
path = History;
sourceTree = "<group>";
};
73D80C56FA670F92E007E712 /* Views */ = {
isa = PBXGroup;
children = (
2EF415E84544334B25BD8E26 /* Components */,
DABAA3652C0B0A54CFC3221B /* Control */,
0C1185300420EEF31B892A3A /* Files */,
5A40B212F0D2055C1C499FCC /* History */,
0D9369EE2F3374EAA1EF332E /* Terminal */,
);
path = Views;
sourceTree = "<group>";
};
AB86DCEEB152D8EA7E8CBD86 = {
isa = PBXGroup;
children = (
C86E333A0549E3B163391090 /* OpenAgentDashboard */,
279F9B8FE97DDCBF76C2E85E /* Products */,
);
sourceTree = "<group>";
};
C786EDDB39D9D19A1A112CE9 /* Models */ = {
isa = PBXGroup;
children = (
3CB591B632D3EF26AB217976 /* ChatMessage.swift */,
BA70A2A73D3A386EAFD69FC4 /* FileEntry.swift */,
D4AB47CF121ABA1946A4D879 /* Mission.swift */,
);
path = Models;
sourceTree = "<group>";
};
C86E333A0549E3B163391090 /* OpenAgentDashboard */ = {
isa = PBXGroup;
children = (
66A48A20D2178760301256C9 /* Assets.xcassets */,
99B57FC3136B64DC87413CA6 /* ContentView.swift */,
4D3D6B3EA3B04DE534F9709A /* Info.plist */,
43A2EBAE84C0FFDCA5E1D66E /* OpenAgentDashboard.entitlements */,
139C740B7D55C13F3B167EF3 /* OpenAgentDashboardApp.swift */,
1B2400F48D7D400DF42A11F0 /* DesignSystem */,
C786EDDB39D9D19A1A112CE9 /* Models */,
E9CA77690CC753DF6D133ACC /* Services */,
73D80C56FA670F92E007E712 /* Views */,
);
path = OpenAgentDashboard;
sourceTree = "<group>";
};
DABAA3652C0B0A54CFC3221B /* Control */ = {
isa = PBXGroup;
children = (
A84519FDE8FC75084938B292 /* ControlView.swift */,
);
path = Control;
sourceTree = "<group>";
};
E9CA77690CC753DF6D133ACC /* Services */ = {
isa = PBXGroup;
children = (
CBC90C32FEF604E025FFBF78 /* APIService.swift */,
);
path = Services;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
DD68473111E6CED00E695F44 /* OpenAgentDashboard */ = {
isa = PBXNativeTarget;
buildConfigurationList = 36DB69EB7A3A5AEB4D9D3B57 /* Build configuration list for PBXNativeTarget "OpenAgentDashboard" */;
buildPhases = (
BE523DA1714AE19926D7309A /* Sources */,
F834FCE2F6EA811F16BF98AE /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = OpenAgentDashboard;
packageProductDependencies = (
);
productName = OpenAgentDashboard;
productReference = F51395D8FB559D3C79AAA0A4 /* OpenAgentDashboard.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
F2797B25B56CE919907DC4F7 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1600;
TargetAttributes = {
DD68473111E6CED00E695F44 = {
DevelopmentTeam = "";
};
};
};
buildConfigurationList = DFB11F92DB10F2E14DD9B35E /* Build configuration list for PBXProject "OpenAgentDashboard" */;
compatibilityVersion = "Xcode 14.0";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
Base,
en,
);
mainGroup = AB86DCEEB152D8EA7E8CBD86;
minimizedProjectReferenceProxies = 1;
preferredProjectObjectVersion = 77;
projectDirPath = "";
projectRoot = "";
targets = (
DD68473111E6CED00E695F44 /* OpenAgentDashboard */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
F834FCE2F6EA811F16BF98AE /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
0620B298DEF91DFCAE050DAC /* Assets.xcassets in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
BE523DA1714AE19926D7309A /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
D64972881E36894950658708 /* APIService.swift in Sources */,
CA70EC5A864C3D007D42E781 /* ChatMessage.swift in Sources */,
AA02567226057045DDD61CB1 /* ContentView.swift in Sources */,
02DB7F25245D03FF72DD8E2E /* ControlView.swift in Sources */,
5152C5313CD5AC01276D0AE6 /* FileEntry.swift in Sources */,
6B87076797C9DFA01E24CC76 /* FilesView.swift in Sources */,
DA4634D7424AF3FC985987E7 /* GlassButton.swift in Sources */,
999ACAA94B0BD81A05288092 /* GlassCard.swift in Sources */,
29372E691F6A5C5D2CCD9331 /* HistoryView.swift in Sources */,
6865FE997D3E1D91D411F6BC /* LoadingView.swift in Sources */,
9BC40E40E1B5622B24328AEB /* Mission.swift in Sources */,
FF9C447978711CBA9185B8B0 /* OpenAgentDashboardApp.swift in Sources */,
FA7E68F22D16E1AC0B5F5E22 /* StatusBadge.swift in Sources */,
4D0CF2666262F45370D000DF /* TerminalView.swift in Sources */,
4B50B97618C0CC469FF64592 /* Theme.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
387AE8B7392A5AF971AD749A /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = OpenAgentDashboard/OpenAgentDashboard.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = OpenAgentDashboard/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = md.thomas.openagent.dashboard;
PRODUCT_NAME = "Open Agent";
SDKROOT = iphoneos;
SWIFT_EMIT_LOC_STRINGS = YES;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
9A248EB7DD7B3E88A6324395 /* 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_SIGNING_REQUIRED = NO;
CODE_SIGN_ENTITLEMENTS = "";
CODE_SIGN_IDENTITY = "";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = "";
ENABLE_PREVIEWS = YES;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
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;
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 6.0;
};
name = Debug;
};
ADC68A4DC006EED0F9D123CC /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = OpenAgentDashboard/OpenAgentDashboard.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = OpenAgentDashboard/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = md.thomas.openagent.dashboard;
PRODUCT_NAME = "Open Agent";
SDKROOT = iphoneos;
SWIFT_EMIT_LOC_STRINGS = YES;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
B45295B3864E2C7973AE65C3 /* 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_SIGNING_REQUIRED = NO;
CODE_SIGN_ENTITLEMENTS = "";
CODE_SIGN_IDENTITY = "";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = "";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_PREVIEWS = YES;
ENABLE_STRICT_OBJC_MSGSEND = YES;
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;
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_VERSION = 6.0;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
36DB69EB7A3A5AEB4D9D3B57 /* Build configuration list for PBXNativeTarget "OpenAgentDashboard" */ = {
isa = XCConfigurationList;
buildConfigurations = (
387AE8B7392A5AF971AD749A /* Debug */,
ADC68A4DC006EED0F9D123CC /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
DFB11F92DB10F2E14DD9B35E /* Build configuration list for PBXProject "OpenAgentDashboard" */ = {
isa = XCConfigurationList;
buildConfigurations = (
9A248EB7DD7B3E88A6324395 /* Debug */,
B45295B3864E2C7973AE65C3 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
/* End XCConfigurationList section */
};
rootObject = F2797B25B56CE919907DC4F7 /* Project object */;
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.945",
"green" : "0.400",
"red" : "0.388"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.945",
"green" : "0.400",
"red" : "0.388"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,13 @@
{
"images" : [
{
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.078",
"green" : "0.071",
"red" : "0.071"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,274 @@
//
// ContentView.swift
// OpenAgentDashboard
//
// Main content view with authentication gate and tab navigation
//
import SwiftUI
struct ContentView: View {
@State private var isAuthenticated = false
@State private var isCheckingAuth = true
@State private var authRequired = false
private let api = APIService.shared
var body: some View {
Group {
if isCheckingAuth {
LoadingView(message: "Connecting...")
.background(Theme.backgroundPrimary.ignoresSafeArea())
} else if authRequired && !isAuthenticated {
LoginView(onLogin: { isAuthenticated = true })
} else {
MainTabView()
}
}
.task {
await checkAuth()
}
}
private func checkAuth() async {
isCheckingAuth = true
do {
let _ = try await api.checkHealth()
authRequired = api.authRequired
isAuthenticated = api.isAuthenticated || !authRequired
} catch {
// If health check fails, assume we need auth
authRequired = true
isAuthenticated = api.isAuthenticated
}
isCheckingAuth = false
}
}
// MARK: - Login View
struct LoginView: View {
let onLogin: () -> Void
@State private var password = ""
@State private var isLoading = false
@State private var errorMessage: String?
@State private var serverURL: String
@FocusState private var isPasswordFocused: Bool
private let api = APIService.shared
init(onLogin: @escaping () -> Void) {
self.onLogin = onLogin
_serverURL = State(initialValue: APIService.shared.baseURL)
}
var body: some View {
ZStack {
// Background
Theme.backgroundPrimary.ignoresSafeArea()
// Gradient accents
RadialGradient(
colors: [Theme.accent.opacity(0.15), .clear],
center: .topTrailing,
startRadius: 50,
endRadius: 400
)
.ignoresSafeArea()
RadialGradient(
colors: [Color.purple.opacity(0.1), .clear],
center: .bottomLeading,
startRadius: 50,
endRadius: 400
)
.ignoresSafeArea()
ScrollView {
VStack(spacing: 32) {
Spacer()
.frame(height: 60)
// Logo
VStack(spacing: 16) {
Image(systemName: "brain.head.profile")
.font(.system(size: 64))
.foregroundStyle(Theme.accent)
.symbolEffect(.pulse, options: .repeating)
VStack(spacing: 4) {
Text("Open Agent")
.font(.largeTitle.bold())
.foregroundStyle(Theme.textPrimary)
Text("Dashboard")
.font(.title3)
.foregroundStyle(Theme.textSecondary)
}
}
// Login form
GlassCard(padding: 24, cornerRadius: 28) {
VStack(spacing: 20) {
// Server URL field
VStack(alignment: .leading, spacing: 8) {
Text("Server URL")
.font(.caption.weight(.medium))
.foregroundStyle(Theme.textSecondary)
TextField("https://agent-backend.example.com", text: $serverURL)
.textFieldStyle(.plain)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.keyboardType(.URL)
.padding(.horizontal, 16)
.padding(.vertical, 14)
.background(Color.white.opacity(0.05))
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(Theme.border, lineWidth: 1)
)
}
// Password field
VStack(alignment: .leading, spacing: 8) {
Text("Password")
.font(.caption.weight(.medium))
.foregroundStyle(Theme.textSecondary)
SecureField("Enter password", text: $password)
.textFieldStyle(.plain)
.focused($isPasswordFocused)
.padding(.horizontal, 16)
.padding(.vertical, 14)
.background(Color.white.opacity(0.05))
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(isPasswordFocused ? Theme.accent.opacity(0.5) : Theme.border, lineWidth: 1)
)
.onSubmit {
login()
}
}
// Error message
if let error = errorMessage {
HStack(spacing: 8) {
Image(systemName: "exclamationmark.circle.fill")
.foregroundStyle(Theme.error)
Text(error)
.font(.caption)
.foregroundStyle(Theme.error)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
// Login button
GlassPrimaryButton(
"Sign In",
icon: "arrow.right",
isLoading: isLoading,
isDisabled: password.isEmpty
) {
login()
}
}
}
.padding(.horizontal, 24)
Spacer()
}
}
}
}
private func login() {
guard !password.isEmpty else { return }
// Update server URL
api.baseURL = serverURL.trimmingCharacters(in: .whitespacesAndNewlines)
isLoading = true
errorMessage = nil
Task {
do {
let _ = try await api.login(password: password)
HapticService.success()
onLogin()
} catch {
errorMessage = error.localizedDescription
HapticService.error()
}
isLoading = false
}
}
}
// MARK: - Main Tab View
struct MainTabView: View {
@State private var selectedTab: TabItem = .control
enum TabItem: String, CaseIterable {
case control = "Control"
case history = "History"
case terminal = "Terminal"
case files = "Files"
var icon: String {
switch self {
case .control: return "message.fill"
case .history: return "clock.fill"
case .terminal: return "terminal.fill"
case .files: return "folder.fill"
}
}
}
var body: some View {
TabView(selection: $selectedTab) {
ForEach(TabItem.allCases, id: \.rawValue) { tab in
NavigationStack {
tabContent(for: tab)
}
.tabItem {
Label(tab.rawValue, systemImage: tab.icon)
}
.tag(tab)
}
}
.tint(Theme.accent)
.onChange(of: selectedTab) { _, _ in
HapticService.selectionChanged()
}
}
@ViewBuilder
private func tabContent(for tab: TabItem) -> some View {
switch tab {
case .control:
ControlView()
case .history:
HistoryView()
case .terminal:
TerminalView()
case .files:
FilesView()
}
}
}
#Preview("Login") {
LoginView(onLogin: {})
}
#Preview("Main") {
MainTabView()
}

View File

@@ -0,0 +1,212 @@
//
// Theme.swift
// OpenAgentDashboard
//
// Native-first, quiet confidence theme tokens
// "Quiet Luxury + Liquid Glass" - Dark-first, Vercel/shadcn inspired
//
import SwiftUI
enum Theme {
// MARK: - Surfaces
// Deep charcoal backgrounds - avoid pure black for quiet luxury feel
/// Primary background: #121214 - deep charcoal, not pure black
static let backgroundPrimary = Color(
uiColor: UIColor { traits in
traits.userInterfaceStyle == .dark
? UIColor(red: 0.071, green: 0.071, blue: 0.078, alpha: 1.0)
: UIColor.systemBackground
}
)
/// Secondary/elevated background: #1C1C1E - iOS system secondary background
static let backgroundSecondary = Color(uiColor: .secondarySystemBackground)
/// Tertiary background: #2C2C2E - for nested elements
static let backgroundTertiary = Color(
uiColor: UIColor { traits in
traits.userInterfaceStyle == .dark
? UIColor(red: 0.17, green: 0.17, blue: 0.18, alpha: 1.0)
: UIColor.tertiarySystemBackground
}
)
/// Card surface: subtle elevation from background
static let card = Color(
uiColor: UIColor { traits in
traits.userInterfaceStyle == .dark
? UIColor(red: 0.11, green: 0.11, blue: 0.12, alpha: 1.0)
: UIColor.secondarySystemBackground
}
)
/// Elevated card: for nested or interactive elements
static let cardElevated = Color(
uiColor: UIColor { traits in
traits.userInterfaceStyle == .dark
? UIColor(red: 0.17, green: 0.17, blue: 0.18, alpha: 1.0)
: UIColor.tertiarySystemBackground
}
)
/// Subtle divider/hairline
static let hairline = Color(uiColor: .separator)
/// Border color with low opacity
static let border = Color.white.opacity(0.06)
static let borderElevated = Color.white.opacity(0.08)
// MARK: - Accent
// Single accent color for primary actions - indigo per style guide
static let accent = Color.indigo
static let accentLight = Color(red: 0.388, green: 0.4, blue: 0.945)
// MARK: - Semantic Colors
static let success = Color(red: 0.133, green: 0.773, blue: 0.369) // #22C55E
static let warning = Color(red: 0.918, green: 0.702, blue: 0.031) // #EAB308
static let error = Color(red: 0.937, green: 0.267, blue: 0.267) // #EF4444
static let info = Color(red: 0.231, green: 0.510, blue: 0.965) // #3B82F6
// MARK: - Text
// Use semantic colors for proper dark/light mode support
static let textPrimary = Color(uiColor: .label)
static let textSecondary = Color(uiColor: .secondaryLabel)
static let textTertiary = Color(uiColor: .tertiaryLabel)
static let textMuted = Color.white.opacity(0.4)
// MARK: - Typography Helpers
static func metric(_ value: Double) -> Text {
Text(value, format: .number.precision(.fractionLength(0)))
.monospacedDigit()
}
static func metric(_ value: Int) -> Text {
Text("\(value)")
.monospacedDigit()
}
}
// MARK: - View Extensions
extension View {
/// Apply the primary background
func themeBackground() -> some View {
background(Theme.backgroundPrimary.ignoresSafeArea())
}
/// Card style with subtle elevation
func themeCard() -> some View {
self
.background(Theme.card)
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
}
/// Elevated card style
func themeCardElevated() -> some View {
self
.background(Theme.cardElevated)
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
}
/// Apply subtle border
func themeBorder() -> some View {
self.overlay(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.stroke(Theme.border, lineWidth: 1)
)
}
}
// MARK: - Button Styles
struct GlassButtonStyle: ButtonStyle {
var isProminent: Bool = false
@ViewBuilder
func makeBody(configuration: Configuration) -> some View {
if isProminent {
configuration.label
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(Theme.accent)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(.white.opacity(0.1), lineWidth: 0.5)
)
.scaleEffect(configuration.isPressed ? 0.97 : 1)
.animation(.easeInOut(duration: 0.15), value: configuration.isPressed)
} else {
configuration.label
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(.ultraThinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(.white.opacity(0.15), lineWidth: 0.5)
)
.scaleEffect(configuration.isPressed ? 0.97 : 1)
.animation(.easeInOut(duration: 0.15), value: configuration.isPressed)
}
}
}
struct GlassProminentButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.padding(.horizontal, 20)
.padding(.vertical, 14)
.foregroundStyle(.white)
.background(
LinearGradient(
colors: [Theme.accent, Theme.accent.opacity(0.85)],
startPoint: .top,
endPoint: .bottom
)
)
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
.shadow(color: Theme.accent.opacity(0.3), radius: 12, y: 6)
.scaleEffect(configuration.isPressed ? 0.97 : 1)
.animation(.easeInOut(duration: 0.15), value: configuration.isPressed)
}
}
extension ButtonStyle where Self == GlassButtonStyle {
static var glass: GlassButtonStyle { GlassButtonStyle() }
static var glassProminent: GlassButtonStyle { GlassButtonStyle(isProminent: true) }
}
// MARK: - Haptics
@MainActor
enum HapticService {
static func lightTap() {
let generator = UIImpactFeedbackGenerator(style: .light)
generator.impactOccurred()
}
static func mediumTap() {
let generator = UIImpactFeedbackGenerator(style: .medium)
generator.impactOccurred()
}
static func selectionChanged() {
let generator = UISelectionFeedbackGenerator()
generator.selectionChanged()
}
static func success() {
let generator = UINotificationFeedbackGenerator()
generator.notificationOccurred(.success)
}
static func error() {
let generator = UINotificationFeedbackGenerator()
generator.notificationOccurred(.error)
}
}

View File

@@ -0,0 +1,58 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Open Agent</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<true/>
</dict>
<key>UILaunchScreen</key>
<dict>
<key>UIColorName</key>
<string>LaunchBackground</string>
</dict>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
</dict>
</plist>

View File

@@ -0,0 +1,90 @@
//
// ChatMessage.swift
// OpenAgentDashboard
//
// Chat message models for the control view
//
import Foundation
enum ChatMessageType {
case user
case assistant(success: Bool, costCents: Int, model: String?)
case thinking(done: Bool, startTime: Date)
case system
case error
}
struct ChatMessage: Identifiable {
let id: String
let type: ChatMessageType
var content: String
let timestamp: Date
init(id: String = UUID().uuidString, type: ChatMessageType, content: String, timestamp: Date = Date()) {
self.id = id
self.type = type
self.content = content
self.timestamp = timestamp
}
var isUser: Bool {
if case .user = type { return true }
return false
}
var isAssistant: Bool {
if case .assistant = type { return true }
return false
}
var isThinking: Bool {
if case .thinking = type { return true }
return false
}
var thinkingDone: Bool {
if case .thinking(let done, _) = type { return done }
return false
}
var displayModel: String? {
if case .assistant(_, _, let model) = type {
if let model = model {
return model.split(separator: "/").last.map(String.init)
}
}
return nil
}
var costFormatted: String? {
if case .assistant(_, let costCents, _) = type, costCents > 0 {
return String(format: "$%.4f", Double(costCents) / 100.0)
}
return nil
}
}
// MARK: - Control Session State
enum ControlRunState: String, Codable {
case idle
case running
case waitingForTool = "waiting_for_tool"
var statusType: StatusType {
switch self {
case .idle: return .idle
case .running: return .running
case .waitingForTool: return .pending
}
}
var label: String {
switch self {
case .idle: return "Idle"
case .running: return "Running"
case .waitingForTool: return "Waiting"
}
}
}

View File

@@ -0,0 +1,71 @@
//
// FileEntry.swift
// OpenAgentDashboard
//
// File system entry models
//
import Foundation
struct FileEntry: Codable, Identifiable {
let name: String
let path: String
let kind: String
let size: Int
let mtime: Int
var id: String { path }
var isDirectory: Bool {
kind == "dir"
}
var isFile: Bool {
kind == "file"
}
var icon: String {
if isDirectory {
return "folder.fill"
}
let ext = (name as NSString).pathExtension.lowercased()
switch ext {
case "swift", "rs", "py", "js", "ts", "tsx", "jsx", "go", "java", "c", "cpp", "h":
return "doc.text.fill"
case "json", "yaml", "yml", "toml", "xml":
return "doc.badge.gearshape.fill"
case "md", "txt", "log":
return "doc.plaintext.fill"
case "png", "jpg", "jpeg", "gif", "svg", "webp":
return "photo.fill"
case "pdf":
return "doc.richtext.fill"
case "zip", "tar", "gz", "rar":
return "doc.zipper"
default:
return "doc.fill"
}
}
var formattedSize: String {
guard isFile else { return "" }
if size < 1024 { return "\(size) B" }
let units = ["KB", "MB", "GB", "TB"]
var value = Double(size) / 1024.0
var unitIndex = 0
while value >= 1024 && unitIndex < units.count - 1 {
value /= 1024.0
unitIndex += 1
}
return value >= 10 ? String(format: "%.0f %@", value, units[unitIndex])
: String(format: "%.1f %@", value, units[unitIndex])
}
var modifiedDate: Date {
Date(timeIntervalSince1970: TimeInterval(mtime))
}
}

View File

@@ -0,0 +1,131 @@
//
// Mission.swift
// OpenAgentDashboard
//
// Mission and task data models
//
import Foundation
enum MissionStatus: String, Codable, CaseIterable {
case active
case completed
case failed
var statusType: StatusType {
switch self {
case .active: return .active
case .completed: return .completed
case .failed: return .failed
}
}
}
struct MissionHistoryEntry: Codable, Identifiable {
var id: String { "\(role)-\(content.prefix(20))" }
let role: String
let content: String
var isUser: Bool {
role == "user"
}
}
struct Mission: Codable, Identifiable, Hashable {
let id: String
var status: MissionStatus
let title: String?
let history: [MissionHistoryEntry]
let createdAt: String
let updatedAt: String
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
static func == (lhs: Mission, rhs: Mission) -> Bool {
lhs.id == rhs.id
}
enum CodingKeys: String, CodingKey {
case id, status, title, history
case createdAt = "created_at"
case updatedAt = "updated_at"
}
var displayTitle: String {
if let title = title, !title.isEmpty {
return title.count > 60 ? String(title.prefix(60)) + "..." : title
}
return "Untitled Mission"
}
var updatedDate: Date? {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter.date(from: updatedAt) ?? ISO8601DateFormatter().date(from: updatedAt)
}
}
enum TaskStatus: String, Codable, CaseIterable {
case pending
case running
case completed
case failed
case cancelled
var statusType: StatusType {
switch self {
case .pending: return .pending
case .running: return .running
case .completed: return .completed
case .failed: return .failed
case .cancelled: return .cancelled
}
}
}
struct TaskState: Codable, Identifiable {
let id: String
let status: TaskStatus
let task: String
let model: String
let iterations: Int
let result: String?
var displayModel: String {
if let lastPart = model.split(separator: "/").last {
return String(lastPart)
}
return model
}
}
struct Run: Codable, Identifiable {
let id: String
let createdAt: String
let status: String
let inputText: String
let finalOutput: String?
let totalCostCents: Int
let summaryText: String?
enum CodingKeys: String, CodingKey {
case id, status
case createdAt = "created_at"
case inputText = "input_text"
case finalOutput = "final_output"
case totalCostCents = "total_cost_cents"
case summaryText = "summary_text"
}
var costDollars: Double {
Double(totalCostCents) / 100.0
}
var createdDate: Date? {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter.date(from: createdAt) ?? ISO8601DateFormatter().date(from: createdAt)
}
}

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
</dict>
</plist>

View File

@@ -0,0 +1,18 @@
//
// OpenAgentDashboardApp.swift
// OpenAgentDashboard
//
// iOS Dashboard for Open Agent with liquid glass design
//
import SwiftUI
@main
struct OpenAgentDashboardApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.preferredColorScheme(.dark)
}
}
}

View File

@@ -0,0 +1,364 @@
//
// APIService.swift
// OpenAgentDashboard
//
// HTTP API client for the Open Agent backend
//
import Foundation
import Observation
@MainActor
@Observable
final class APIService {
static let shared = APIService()
nonisolated init() {}
// Configuration
var baseURL: String {
get { UserDefaults.standard.string(forKey: "api_base_url") ?? "https://agent-backend.thomas.md" }
set { UserDefaults.standard.set(newValue, forKey: "api_base_url") }
}
private var jwtToken: String? {
get { UserDefaults.standard.string(forKey: "jwt_token") }
set { UserDefaults.standard.set(newValue, forKey: "jwt_token") }
}
var isAuthenticated: Bool {
jwtToken != nil
}
var authRequired: Bool = false
// MARK: - Authentication
func login(password: String) async throws -> Bool {
struct LoginRequest: Encodable {
let password: String
}
struct LoginResponse: Decodable {
let token: String
let exp: Int
}
let response: LoginResponse = try await post("/api/auth/login", body: LoginRequest(password: password), authenticated: false)
jwtToken = response.token
return true
}
func logout() {
jwtToken = nil
}
func checkHealth() async throws -> Bool {
struct HealthResponse: Decodable {
let status: String
let authRequired: Bool
enum CodingKeys: String, CodingKey {
case status
case authRequired = "auth_required"
}
}
let response: HealthResponse = try await get("/api/health", authenticated: false)
authRequired = response.authRequired
return response.status == "ok"
}
// MARK: - Missions
func listMissions() async throws -> [Mission] {
try await get("/api/control/missions")
}
func getMission(id: String) async throws -> Mission {
try await get("/api/control/missions/\(id)")
}
func getCurrentMission() async throws -> Mission? {
try await get("/api/control/missions/current")
}
func createMission() async throws -> Mission {
try await post("/api/control/missions", body: EmptyBody())
}
func loadMission(id: String) async throws -> Mission {
try await post("/api/control/missions/\(id)/load", body: EmptyBody())
}
func setMissionStatus(id: String, status: MissionStatus) async throws {
struct StatusRequest: Encodable {
let status: String
}
let _: EmptyResponse = try await post("/api/control/missions/\(id)/status", body: StatusRequest(status: status.rawValue))
}
// MARK: - Control
func sendMessage(content: String) async throws -> (id: String, queued: Bool) {
struct MessageRequest: Encodable {
let content: String
}
struct MessageResponse: Decodable {
let id: String
let queued: Bool
}
let response: MessageResponse = try await post("/api/control/message", body: MessageRequest(content: content))
return (response.id, response.queued)
}
func cancelControl() async throws {
let _: EmptyResponse = try await post("/api/control/cancel", body: EmptyBody())
}
// MARK: - Tasks
func listTasks() async throws -> [TaskState] {
try await get("/api/tasks")
}
// MARK: - Runs
func listRuns(limit: Int = 20, offset: Int = 0) async throws -> [Run] {
struct RunsResponse: Decodable {
let runs: [Run]
}
let response: RunsResponse = try await get("/api/runs?limit=\(limit)&offset=\(offset)")
return response.runs
}
// MARK: - File System
func listDirectory(path: String) async throws -> [FileEntry] {
try await get("/api/fs/list?path=\(path.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? path)")
}
func createDirectory(path: String) async throws {
struct MkdirRequest: Encodable {
let path: String
}
let _: EmptyResponse = try await post("/api/fs/mkdir", body: MkdirRequest(path: path))
}
func deleteFile(path: String, recursive: Bool = false) async throws {
struct RmRequest: Encodable {
let path: String
let recursive: Bool
}
let _: EmptyResponse = try await post("/api/fs/rm", body: RmRequest(path: path, recursive: recursive))
}
func downloadURL(path: String) -> URL? {
guard var components = URLComponents(string: baseURL) else { return nil }
components.path = "/api/fs/download"
components.queryItems = [URLQueryItem(name: "path", value: path)]
return components.url
}
func uploadFile(data: Data, fileName: String, directory: String) async throws -> String {
guard let url = URL(string: "\(baseURL)/api/fs/upload?path=\(directory.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? directory)") else {
throw APIError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
if let token = jwtToken {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
let boundary = UUID().uuidString
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
var body = Data()
body.append("--\(boundary)\r\n".data(using: .utf8)!)
body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(fileName)\"\r\n".data(using: .utf8)!)
body.append("Content-Type: application/octet-stream\r\n\r\n".data(using: .utf8)!)
body.append(data)
body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)
request.httpBody = body
let (responseData, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw APIError.invalidResponse
}
if httpResponse.statusCode == 401 {
logout()
throw APIError.unauthorized
}
guard httpResponse.statusCode >= 200 && httpResponse.statusCode < 300 else {
throw APIError.httpError(httpResponse.statusCode, String(data: responseData, encoding: .utf8))
}
struct UploadResponse: Decodable {
let path: String
}
let uploadResponse = try JSONDecoder().decode(UploadResponse.self, from: responseData)
return uploadResponse.path
}
// MARK: - SSE Streaming
func streamControl(onEvent: @escaping (String, [String: Any]) -> Void) -> Task<Void, Never> {
Task {
guard let url = URL(string: "\(baseURL)/api/control/stream") else { return }
var request = URLRequest(url: url)
request.setValue("text/event-stream", forHTTPHeaderField: "Accept")
if let token = jwtToken {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
do {
let (stream, _) = try await URLSession.shared.bytes(for: request)
var buffer = ""
for try await byte in stream {
guard !Task.isCancelled else { break }
if let char = String(bytes: [byte], encoding: .utf8) {
buffer.append(char)
// Look for double newline (end of SSE event)
while let range = buffer.range(of: "\n\n") {
let eventString = String(buffer[..<range.lowerBound])
buffer = String(buffer[range.upperBound...])
parseSSEEvent(eventString, onEvent: onEvent)
}
}
}
} catch {
if !Task.isCancelled {
onEvent("error", ["message": "Stream connection failed: \(error.localizedDescription)"])
}
}
}
}
private func parseSSEEvent(_ eventString: String, onEvent: @escaping (String, [String: Any]) -> Void) {
var eventType = "message"
var dataString = ""
for line in eventString.split(separator: "\n", omittingEmptySubsequences: false) {
let lineStr = String(line)
if lineStr.hasPrefix("event:") {
eventType = String(lineStr.dropFirst(6)).trimmingCharacters(in: .whitespaces)
} else if lineStr.hasPrefix("data:") {
dataString += String(lineStr.dropFirst(5)).trimmingCharacters(in: .whitespaces)
}
}
guard !dataString.isEmpty else { return }
do {
if let data = dataString.data(using: .utf8),
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] {
onEvent(eventType, json)
}
} catch {
// Ignore parse errors
}
}
// MARK: - Private Helpers
private struct EmptyBody: Encodable {}
private struct EmptyResponse: Decodable {}
private func get<T: Decodable>(_ path: String, authenticated: Bool = true) async throws -> T {
guard let url = URL(string: "\(baseURL)\(path)") else {
throw APIError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = "GET"
if authenticated, let token = jwtToken {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
return try await execute(request)
}
private func post<T: Decodable, B: Encodable>(_ path: String, body: B, authenticated: Bool = true) async throws -> T {
guard let url = URL(string: "\(baseURL)\(path)") else {
throw APIError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
if authenticated, let token = jwtToken {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
request.httpBody = try JSONEncoder().encode(body)
return try await execute(request)
}
private func execute<T: Decodable>(_ request: URLRequest) async throws -> T {
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw APIError.invalidResponse
}
if httpResponse.statusCode == 401 {
logout()
throw APIError.unauthorized
}
guard httpResponse.statusCode >= 200 && httpResponse.statusCode < 300 else {
throw APIError.httpError(httpResponse.statusCode, String(data: data, encoding: .utf8))
}
// Handle empty responses
if data.isEmpty || (T.self == EmptyResponse.self) {
if let empty = EmptyResponse() as? T {
return empty
}
}
let decoder = JSONDecoder()
return try decoder.decode(T.self, from: data)
}
}
enum APIError: LocalizedError {
case invalidURL
case invalidResponse
case unauthorized
case httpError(Int, String?)
case decodingError(Error)
var errorDescription: String? {
switch self {
case .invalidURL:
return "Invalid URL"
case .invalidResponse:
return "Invalid response from server"
case .unauthorized:
return "Authentication required"
case .httpError(let code, let message):
return "HTTP \(code): \(message ?? "Unknown error")"
case .decodingError(let error):
return "Failed to decode response: \(error.localizedDescription)"
}
}
}

View File

@@ -0,0 +1,248 @@
//
// GlassButton.swift
// OpenAgentDashboard
//
// Glass morphism button components
//
import SwiftUI
struct GlassButton: View {
let title: String
let icon: String?
let action: () -> Void
@State private var isPressed = false
init(_ title: String, icon: String? = nil, action: @escaping () -> Void) {
self.title = title
self.icon = icon
self.action = action
}
var body: some View {
Button(action: action) {
HStack(spacing: 8) {
if let icon = icon {
Image(systemName: icon)
.font(.system(size: 16, weight: .semibold))
}
Text(title)
.font(.system(size: 17, weight: .semibold))
}
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
.background(.ultraThinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.stroke(.white.opacity(0.2), lineWidth: 0.5)
)
.shadow(color: .black.opacity(0.06), radius: 8, y: 4)
.scaleEffect(isPressed ? 0.97 : 1)
}
.buttonStyle(.plain)
.simultaneousGesture(
DragGesture(minimumDistance: 0)
.onChanged { _ in
withAnimation(.easeInOut(duration: 0.1)) { isPressed = true }
}
.onEnded { _ in
withAnimation(.easeInOut(duration: 0.15)) { isPressed = false }
}
)
}
}
struct GlassPrimaryButton: View {
let title: String
let icon: String?
let action: () -> Void
var isLoading: Bool = false
var isDisabled: Bool = false
@State private var isPressed = false
init(_ title: String, icon: String? = nil, isLoading: Bool = false, isDisabled: Bool = false, action: @escaping () -> Void) {
self.title = title
self.icon = icon
self.isLoading = isLoading
self.isDisabled = isDisabled
self.action = action
}
var body: some View {
Button(action: action) {
HStack(spacing: 8) {
if isLoading {
ProgressView()
.tint(.white)
} else {
if let icon = icon {
Image(systemName: icon)
.font(.system(size: 16, weight: .semibold))
}
Text(title)
.font(.system(size: 17, weight: .semibold))
}
}
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
.background(
LinearGradient(
colors: [Theme.accent, Theme.accent.opacity(0.85)],
startPoint: .top,
endPoint: .bottom
)
)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.shadow(color: Theme.accent.opacity(0.3), radius: 12, y: 6)
.scaleEffect(isPressed ? 0.97 : 1)
.opacity(isDisabled ? 0.5 : 1)
}
.buttonStyle(.plain)
.disabled(isLoading || isDisabled)
.simultaneousGesture(
DragGesture(minimumDistance: 0)
.onChanged { _ in
guard !isLoading && !isDisabled else { return }
withAnimation(.easeInOut(duration: 0.1)) { isPressed = true }
}
.onEnded { _ in
withAnimation(.easeInOut(duration: 0.15)) { isPressed = false }
}
)
}
}
struct GlassIconButton: View {
let icon: String
let action: () -> Void
var size: CGFloat = 44
var tint: Color? = nil
@State private var isPressed = false
var body: some View {
Button(action: action) {
Image(systemName: icon)
.font(.system(size: size * 0.4, weight: .semibold))
.foregroundStyle(tint ?? .primary)
.frame(width: size, height: size)
.background(.ultraThinMaterial)
.clipShape(Circle())
.overlay(
Circle()
.stroke(.white.opacity(0.2), lineWidth: 0.5)
)
.shadow(color: .black.opacity(0.06), radius: 6, y: 3)
.scaleEffect(isPressed ? 0.92 : 1)
}
.buttonStyle(.plain)
.simultaneousGesture(
DragGesture(minimumDistance: 0)
.onChanged { _ in
withAnimation(.easeInOut(duration: 0.1)) { isPressed = true }
}
.onEnded { _ in
withAnimation(.easeInOut(duration: 0.15)) { isPressed = false }
}
)
}
}
struct GlassDestructiveButton: View {
let title: String
let icon: String?
let action: () -> Void
@State private var isPressed = false
init(_ title: String, icon: String? = nil, action: @escaping () -> Void) {
self.title = title
self.icon = icon
self.action = action
}
var body: some View {
Button(action: action) {
HStack(spacing: 8) {
if let icon = icon {
Image(systemName: icon)
.font(.system(size: 16, weight: .semibold))
}
Text(title)
.font(.system(size: 17, weight: .semibold))
}
.foregroundStyle(Theme.error)
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
.background(Theme.error.opacity(0.15))
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.stroke(Theme.error.opacity(0.3), lineWidth: 0.5)
)
.scaleEffect(isPressed ? 0.97 : 1)
}
.buttonStyle(.plain)
.simultaneousGesture(
DragGesture(minimumDistance: 0)
.onChanged { _ in
withAnimation(.easeInOut(duration: 0.1)) { isPressed = true }
}
.onEnded { _ in
withAnimation(.easeInOut(duration: 0.15)) { isPressed = false }
}
)
}
}
#Preview {
ZStack {
LinearGradient(
colors: [.orange.opacity(0.5), .pink.opacity(0.4)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.ignoresSafeArea()
VStack(spacing: 16) {
GlassButton("Secondary Button", icon: "message") {
print("Tapped")
}
GlassPrimaryButton("Send Message", icon: "paperplane.fill") {
print("Tapped")
}
GlassPrimaryButton("Loading...", isLoading: true) {
print("Tapped")
}
GlassDestructiveButton("Delete", icon: "trash") {
print("Delete")
}
HStack(spacing: 16) {
GlassIconButton(icon: "message.fill") {
print("Message")
}
GlassIconButton(icon: "folder.fill") {
print("Folder")
}
GlassIconButton(icon: "terminal.fill", action: {
print("Terminal")
}, tint: Theme.accent)
GlassIconButton(icon: "xmark", action: {
print("Close")
}, size: 36)
}
}
.padding()
}
}

View File

@@ -0,0 +1,174 @@
//
// GlassCard.swift
// OpenAgentDashboard
//
// Beautiful glass morphism card components with liquid glass effects
//
import SwiftUI
struct GlassCard<Content: View>: View {
let content: Content
var padding: CGFloat = 20
var cornerRadius: CGFloat = 24
init(
padding: CGFloat = 20,
cornerRadius: CGFloat = 24,
@ViewBuilder content: () -> Content
) {
self.content = content()
self.padding = padding
self.cornerRadius = cornerRadius
}
var body: some View {
content
.padding(padding)
.background(.ultraThinMaterial)
.clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
.shadow(color: .black.opacity(0.06), radius: 16, y: 8)
.overlay(
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
.stroke(.white.opacity(0.2), lineWidth: 0.5)
)
}
}
struct GlassCardLight<Content: View>: View {
let content: Content
var padding: CGFloat = 16
var cornerRadius: CGFloat = 20
init(
padding: CGFloat = 16,
cornerRadius: CGFloat = 20,
@ViewBuilder content: () -> Content
) {
self.content = content()
self.padding = padding
self.cornerRadius = cornerRadius
}
var body: some View {
content
.padding(padding)
.background(.thinMaterial)
.clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
.shadow(color: .black.opacity(0.04), radius: 8, y: 4)
}
}
struct GlassCardThick<Content: View>: View {
let content: Content
var padding: CGFloat = 20
var cornerRadius: CGFloat = 24
init(
padding: CGFloat = 20,
cornerRadius: CGFloat = 24,
@ViewBuilder content: () -> Content
) {
self.content = content()
self.padding = padding
self.cornerRadius = cornerRadius
}
var body: some View {
content
.padding(padding)
.background(.thickMaterial)
.clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
.shadow(color: .black.opacity(0.08), radius: 20, y: 10)
.overlay(
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
.stroke(.white.opacity(0.25), lineWidth: 1)
)
}
}
/// Interactive glass card with hover/press states
struct InteractiveGlassCard<Content: View>: View {
let content: Content
var padding: CGFloat = 16
var cornerRadius: CGFloat = 16
let action: () -> Void
@State private var isPressed = false
init(
padding: CGFloat = 16,
cornerRadius: CGFloat = 16,
action: @escaping () -> Void,
@ViewBuilder content: () -> Content
) {
self.content = content()
self.padding = padding
self.cornerRadius = cornerRadius
self.action = action
}
var body: some View {
Button(action: action) {
content
.padding(padding)
.background(.ultraThinMaterial.opacity(isPressed ? 0.8 : 1))
.clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
.stroke(.white.opacity(isPressed ? 0.25 : 0.12), lineWidth: 0.5)
)
.scaleEffect(isPressed ? 0.98 : 1)
}
.buttonStyle(.plain)
.simultaneousGesture(
DragGesture(minimumDistance: 0)
.onChanged { _ in
withAnimation(.easeInOut(duration: 0.1)) { isPressed = true }
}
.onEnded { _ in
withAnimation(.easeInOut(duration: 0.15)) { isPressed = false }
}
)
}
}
#Preview {
ZStack {
LinearGradient(
colors: [.indigo.opacity(0.6), .purple.opacity(0.4)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.ignoresSafeArea()
VStack(spacing: 20) {
GlassCard {
VStack(alignment: .leading, spacing: 8) {
Text("Glass Card")
.font(.headline)
Text("Beautiful translucent design")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
GlassCardLight {
Text("Light Glass Card")
.frame(maxWidth: .infinity)
}
GlassCardThick {
Text("Thick Glass Card")
.frame(maxWidth: .infinity)
}
InteractiveGlassCard(action: { print("Tapped") }) {
Text("Interactive Card - Tap me!")
.frame(maxWidth: .infinity)
}
}
.padding()
}
}

View File

@@ -0,0 +1,144 @@
//
// LoadingView.swift
// OpenAgentDashboard
//
// Loading indicators and shimmer effects
//
import SwiftUI
struct LoadingView: View {
var message: String = "Loading..."
var body: some View {
VStack(spacing: 16) {
ProgressView()
.scaleEffect(1.2)
.tint(Theme.accent)
Text(message)
.font(.subheadline)
.foregroundStyle(Theme.textSecondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
struct ShimmerView: View {
@State private var isAnimating = false
var body: some View {
LinearGradient(
colors: [
Color.white.opacity(0.04),
Color.white.opacity(0.08),
Color.white.opacity(0.04)
],
startPoint: .leading,
endPoint: .trailing
)
.offset(x: isAnimating ? 300 : -300)
.animation(.linear(duration: 1.5).repeatForever(autoreverses: false), value: isAnimating)
.onAppear {
isAnimating = true
}
}
}
struct ShimmerRow: View {
var height: CGFloat = 16
var width: CGFloat? = nil
var body: some View {
RoundedRectangle(cornerRadius: 4)
.fill(Color.white.opacity(0.06))
.frame(width: width, height: height)
.overlay(ShimmerView())
.clipShape(RoundedRectangle(cornerRadius: 4))
}
}
struct ShimmerCard: View {
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 12) {
RoundedRectangle(cornerRadius: 8)
.fill(Color.white.opacity(0.06))
.frame(width: 40, height: 40)
.overlay(ShimmerView())
.clipShape(RoundedRectangle(cornerRadius: 8))
VStack(alignment: .leading, spacing: 6) {
ShimmerRow(height: 14, width: 120)
ShimmerRow(height: 12, width: 80)
}
}
ShimmerRow(height: 12)
ShimmerRow(height: 12, width: 200)
}
.padding(16)
.background(Color.white.opacity(0.03))
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
}
}
struct EmptyStateView: View {
let icon: String
let title: String
let message: String
var action: (() -> Void)? = nil
var actionLabel: String = "Try Again"
var body: some View {
VStack(spacing: 20) {
Image(systemName: icon)
.font(.system(size: 48))
.foregroundStyle(Theme.textTertiary)
VStack(spacing: 8) {
Text(title)
.font(.title3.bold())
.foregroundStyle(Theme.textPrimary)
Text(message)
.font(.subheadline)
.foregroundStyle(Theme.textSecondary)
.multilineTextAlignment(.center)
}
if let action = action {
Button(action: action) {
Text(actionLabel)
.font(.subheadline.weight(.semibold))
.foregroundStyle(Theme.accent)
.padding(.horizontal, 20)
.padding(.vertical, 10)
.background(Theme.accent.opacity(0.15))
.clipShape(Capsule())
}
}
}
.padding(32)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
#Preview {
VStack(spacing: 24) {
LoadingView()
.frame(height: 150)
ShimmerCard()
.padding()
EmptyStateView(
icon: "message.badge.filled.fill",
title: "No Messages",
message: "Start a conversation with the agent",
action: { print("Tapped") }
)
.frame(height: 250)
}
.background(Theme.backgroundPrimary)
}

View File

@@ -0,0 +1,155 @@
//
// StatusBadge.swift
// OpenAgentDashboard
//
// Status indicator badges with semantic colors
//
import SwiftUI
enum StatusType {
case pending
case running
case active
case completed
case failed
case cancelled
case idle
case error
case connected
case disconnected
case connecting
var color: Color {
switch self {
case .pending, .idle:
return Theme.textMuted
case .running, .active, .connecting:
return Theme.accent
case .completed, .connected:
return Theme.success
case .failed, .error:
return Theme.error
case .cancelled, .disconnected:
return Theme.textTertiary
}
}
var backgroundColor: Color {
color.opacity(0.15)
}
var label: String {
switch self {
case .pending: return "Pending"
case .running: return "Running"
case .active: return "Active"
case .completed: return "Completed"
case .failed: return "Failed"
case .cancelled: return "Cancelled"
case .idle: return "Idle"
case .error: return "Error"
case .connected: return "Connected"
case .disconnected: return "Disconnected"
case .connecting: return "Connecting"
}
}
var icon: String {
switch self {
case .pending: return "clock"
case .running, .connecting: return "arrow.trianglehead.2.clockwise"
case .active: return "circle.fill"
case .completed: return "checkmark.circle.fill"
case .failed, .error: return "xmark.circle.fill"
case .cancelled: return "slash.circle"
case .idle: return "moon.fill"
case .connected: return "wifi"
case .disconnected: return "wifi.slash"
}
}
var shouldPulse: Bool {
switch self {
case .running, .active, .connecting:
return true
default:
return false
}
}
}
struct StatusBadge: View {
let status: StatusType
var showIcon: Bool = true
var compact: Bool = false
var body: some View {
HStack(spacing: compact ? 4 : 6) {
if showIcon {
Image(systemName: status.icon)
.font(.system(size: compact ? 10 : 12, weight: .medium))
.symbolEffect(.pulse, options: status.shouldPulse ? .repeating : .nonRepeating)
}
Text(status.label)
.font(.system(size: compact ? 10 : 11, weight: .semibold))
.textCase(.uppercase)
}
.foregroundStyle(status.color)
.padding(.horizontal, compact ? 8 : 10)
.padding(.vertical, compact ? 4 : 6)
.background(status.backgroundColor)
.clipShape(Capsule())
}
}
struct StatusDot: View {
let status: StatusType
var size: CGFloat = 8
var body: some View {
Circle()
.fill(status.color)
.frame(width: size, height: size)
.overlay {
if status.shouldPulse {
Circle()
.stroke(status.color.opacity(0.5), lineWidth: 2)
.scaleEffect(1.5)
.opacity(0.5)
}
}
}
}
#Preview {
VStack(spacing: 16) {
HStack(spacing: 8) {
StatusBadge(status: .pending)
StatusBadge(status: .running)
StatusBadge(status: .completed)
}
HStack(spacing: 8) {
StatusBadge(status: .failed)
StatusBadge(status: .cancelled)
StatusBadge(status: .active)
}
HStack(spacing: 8) {
StatusBadge(status: .connected, compact: true)
StatusBadge(status: .disconnected, compact: true)
StatusBadge(status: .connecting, compact: true)
}
Divider()
HStack(spacing: 16) {
ForEach([StatusType.active, .completed, .failed, .idle], id: \.label) { status in
StatusDot(status: status)
}
}
}
.padding()
.background(Theme.backgroundPrimary)
}

View File

@@ -0,0 +1,538 @@
//
// ControlView.swift
// OpenAgentDashboard
//
// Chat interface for the AI agent with real-time streaming
//
import SwiftUI
struct ControlView: View {
@State private var messages: [ChatMessage] = []
@State private var inputText = ""
@State private var runState: ControlRunState = .idle
@State private var queueLength = 0
@State private var currentMission: Mission?
@State private var isLoading = true
@State private var streamTask: Task<Void, Never>?
@State private var showMissionMenu = false
@FocusState private var isInputFocused: Bool
private let api = APIService.shared
var body: some View {
ZStack {
// Background with subtle accent glow
Theme.backgroundPrimary.ignoresSafeArea()
// Subtle radial gradients for liquid glass refraction
backgroundGlows
VStack(spacing: 0) {
// Header
headerView
// Messages
messagesView
// Input area
inputView
}
}
.navigationBarTitleDisplayMode(.inline)
.task {
await loadCurrentMission()
startStreaming()
}
.onDisappear {
streamTask?.cancel()
}
}
// MARK: - Background
private var backgroundGlows: some View {
ZStack {
RadialGradient(
colors: [Theme.accent.opacity(0.08), .clear],
center: .topTrailing,
startRadius: 20,
endRadius: 400
)
.ignoresSafeArea()
.allowsHitTesting(false)
RadialGradient(
colors: [Color.white.opacity(0.03), .clear],
center: .bottomLeading,
startRadius: 30,
endRadius: 500
)
.ignoresSafeArea()
.allowsHitTesting(false)
}
}
// MARK: - Header
private var headerView: some View {
HStack(spacing: 12) {
// Mission info
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 8) {
Text(currentMission?.displayTitle ?? "Control")
.font(.headline)
.foregroundStyle(Theme.textPrimary)
.lineLimit(1)
if let status = currentMission?.status {
StatusBadge(status: status.statusType, compact: true)
}
}
HStack(spacing: 8) {
StatusDot(status: runState.statusType, size: 6)
Text(runState.label)
.font(.caption)
.foregroundStyle(Theme.textSecondary)
if queueLength > 0 {
Text("• Queue: \(queueLength)")
.font(.caption)
.foregroundStyle(Theme.textTertiary)
}
}
}
Spacer()
// Mission menu
Menu {
Button {
Task { await createNewMission() }
} label: {
Label("New Mission", systemImage: "plus")
}
if let mission = currentMission {
Divider()
Button {
Task { await setMissionStatus(.completed) }
} label: {
Label("Mark Complete", systemImage: "checkmark.circle")
}
Button(role: .destructive) {
Task { await setMissionStatus(.failed) }
} label: {
Label("Mark Failed", systemImage: "xmark.circle")
}
if mission.status != .active {
Button {
Task { await setMissionStatus(.active) }
} label: {
Label("Reactivate", systemImage: "arrow.clockwise")
}
}
}
} label: {
GlassIconButton(icon: "ellipsis", action: {}, size: 36)
.allowsHitTesting(false)
}
}
.padding(.horizontal)
.padding(.vertical, 12)
.background(.ultraThinMaterial)
}
// MARK: - Messages
private var messagesView: some View {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(spacing: 16) {
if messages.isEmpty && !isLoading {
emptyStateView
} else if isLoading {
LoadingView(message: "Loading conversation...")
.frame(height: 200)
} else {
ForEach(messages) { message in
MessageBubble(message: message)
.id(message.id)
}
}
}
.padding()
}
.onChange(of: messages.count) { _, _ in
if let lastMessage = messages.last {
withAnimation {
proxy.scrollTo(lastMessage.id, anchor: .bottom)
}
}
}
}
}
private var emptyStateView: some View {
VStack(spacing: 20) {
Image(systemName: "bubble.left.and.bubble.right.fill")
.font(.system(size: 48))
.foregroundStyle(Theme.accent.opacity(0.6))
VStack(spacing: 8) {
Text("Start a Conversation")
.font(.title3.bold())
.foregroundStyle(Theme.textPrimary)
Text("Send a message to the AI agent to begin")
.font(.subheadline)
.foregroundStyle(Theme.textSecondary)
.multilineTextAlignment(.center)
}
}
.frame(maxHeight: .infinity)
.padding(40)
}
// MARK: - Input
private var inputView: some View {
VStack(spacing: 0) {
Divider()
.background(Theme.border)
HStack(spacing: 12) {
// Text input
TextField("Message the agent...", text: $inputText, axis: .vertical)
.textFieldStyle(.plain)
.font(.body)
.lineLimit(1...5)
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(.ultraThinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.stroke(isInputFocused ? Theme.accent.opacity(0.5) : Theme.border, lineWidth: 1)
)
.focused($isInputFocused)
.onSubmit {
sendMessage()
}
// Send/Stop button
if runState != .idle {
Button {
Task { await cancelRun() }
} label: {
Image(systemName: "stop.fill")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 44, height: 44)
.background(Theme.error)
.clipShape(Circle())
}
} else {
Button {
sendMessage()
} label: {
Image(systemName: "paperplane.fill")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 44, height: 44)
.background(inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? Theme.accent.opacity(0.5) : Theme.accent)
.clipShape(Circle())
}
.disabled(inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
.padding()
.background(.ultraThinMaterial)
}
}
// MARK: - Actions
private func loadCurrentMission() async {
isLoading = true
defer { isLoading = false }
do {
if let mission = try await api.getCurrentMission() {
currentMission = mission
messages = mission.history.enumerated().map { index, entry in
ChatMessage(
id: "\(mission.id)-\(index)",
type: entry.isUser ? .user : .assistant(success: true, costCents: 0, model: nil),
content: entry.content
)
}
}
} catch {
print("Failed to load mission: \(error)")
}
}
private func createNewMission() async {
do {
let mission = try await api.createMission()
currentMission = mission
messages = []
HapticService.success()
} catch {
print("Failed to create mission: \(error)")
HapticService.error()
}
}
private func setMissionStatus(_ status: MissionStatus) async {
guard let mission = currentMission else { return }
do {
try await api.setMissionStatus(id: mission.id, status: status)
currentMission?.status = status
HapticService.success()
} catch {
print("Failed to set status: \(error)")
HapticService.error()
}
}
private func sendMessage() {
let content = inputText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !content.isEmpty else { return }
inputText = ""
HapticService.lightTap()
Task {
do {
let _ = try await api.sendMessage(content: content)
} catch {
print("Failed to send message: \(error)")
HapticService.error()
}
}
}
private func cancelRun() async {
do {
try await api.cancelControl()
HapticService.success()
} catch {
print("Failed to cancel: \(error)")
HapticService.error()
}
}
private func startStreaming() {
streamTask = api.streamControl { eventType, data in
Task { @MainActor in
handleStreamEvent(type: eventType, data: data)
}
}
}
private func handleStreamEvent(type: String, data: [String: Any]) {
switch type {
case "status":
if let state = data["state"] as? String {
runState = ControlRunState(rawValue: state) ?? .idle
}
if let queue = data["queue_len"] as? Int {
queueLength = queue
}
case "user_message":
if let content = data["content"] as? String,
let id = data["id"] as? String {
let message = ChatMessage(id: id, type: .user, content: content)
messages.append(message)
}
case "assistant_message":
if let content = data["content"] as? String,
let id = data["id"] as? String {
let success = data["success"] as? Bool ?? true
let costCents = data["cost_cents"] as? Int ?? 0
let model = data["model"] as? String
// Remove any incomplete thinking messages
messages.removeAll { $0.isThinking && !$0.thinkingDone }
let message = ChatMessage(
id: id,
type: .assistant(success: success, costCents: costCents, model: model),
content: content
)
messages.append(message)
}
case "thinking":
if let content = data["content"] as? String {
let done = data["done"] as? Bool ?? false
// Find existing thinking message or create new
if let index = messages.lastIndex(where: { $0.isThinking && !$0.thinkingDone }) {
messages[index].content += "\n\n---\n\n" + content
if done {
messages[index] = ChatMessage(
id: messages[index].id,
type: .thinking(done: true, startTime: Date()),
content: messages[index].content
)
}
} else if !done {
let message = ChatMessage(
id: "thinking-\(Date().timeIntervalSince1970)",
type: .thinking(done: false, startTime: Date()),
content: content
)
messages.append(message)
}
}
case "error":
if let errorMessage = data["message"] as? String {
let message = ChatMessage(
id: "error-\(Date().timeIntervalSince1970)",
type: .error,
content: errorMessage
)
messages.append(message)
}
default:
break
}
}
}
// MARK: - Message Bubble
private struct MessageBubble: View {
let message: ChatMessage
var body: some View {
HStack(alignment: .top, spacing: 10) {
if message.isUser {
Spacer(minLength: 60)
userBubble
} else if message.isThinking {
thinkingBubble
Spacer(minLength: 60)
} else {
assistantBubble
Spacer(minLength: 60)
}
}
}
private var userBubble: some View {
VStack(alignment: .trailing, spacing: 4) {
Text(message.content)
.font(.body)
.foregroundStyle(.white)
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(Theme.accent)
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
.clipShape(
.rect(
topLeadingRadius: 20,
bottomLeadingRadius: 20,
bottomTrailingRadius: 6,
topTrailingRadius: 20
)
)
}
}
private var assistantBubble: some View {
VStack(alignment: .leading, spacing: 8) {
// Status header for assistant messages
if case .assistant(let success, _, let model) = message.type {
HStack(spacing: 6) {
Image(systemName: success ? "checkmark.circle.fill" : "xmark.circle.fill")
.font(.caption2)
.foregroundStyle(success ? Theme.success : Theme.error)
if let model = message.displayModel {
Text(model)
.font(.caption2.monospaced())
.foregroundStyle(Theme.textTertiary)
}
if let cost = message.costFormatted {
Text("")
.foregroundStyle(Theme.textMuted)
Text(cost)
.font(.caption2.monospaced())
.foregroundStyle(Theme.success)
}
}
}
Text(message.content)
.font(.body)
.foregroundStyle(Theme.textPrimary)
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(.ultraThinMaterial)
.clipShape(
.rect(
topLeadingRadius: 20,
bottomLeadingRadius: 6,
bottomTrailingRadius: 20,
topTrailingRadius: 20
)
)
.overlay(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.stroke(Theme.border, lineWidth: 0.5)
)
}
}
private var thinkingBubble: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 6) {
Image(systemName: "brain")
.font(.caption)
.foregroundStyle(Theme.accent)
.symbolEffect(.pulse, options: message.thinkingDone ? .nonRepeating : .repeating)
Text(message.thinkingDone ? "Thought" : "Thinking...")
.font(.caption)
.foregroundStyle(Theme.textSecondary)
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Theme.accent.opacity(0.1))
.clipShape(Capsule())
if !message.content.isEmpty {
Text(message.content)
.font(.caption)
.foregroundStyle(Theme.textTertiary)
.lineLimit(message.thinkingDone ? 3 : nil)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color.white.opacity(0.02))
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
}
}
}
}
#Preview {
NavigationStack {
ControlView()
}
}

View File

@@ -0,0 +1,407 @@
//
// FilesView.swift
// OpenAgentDashboard
//
// Remote file explorer with SFTP-like functionality
//
import SwiftUI
import UniformTypeIdentifiers
struct FilesView: View {
@State private var currentPath = "/root/context"
@State private var entries: [FileEntry] = []
@State private var isLoading = false
@State private var errorMessage: String?
@State private var selectedEntry: FileEntry?
@State private var showingDeleteAlert = false
@State private var showingNewFolderAlert = false
@State private var newFolderName = ""
@State private var isImporting = false
private let api = APIService.shared
private var sortedEntries: [FileEntry] {
let dirs = entries.filter { $0.isDirectory }.sorted { $0.name < $1.name }
let files = entries.filter { !$0.isDirectory }.sorted { $0.name < $1.name }
return dirs + files
}
private var breadcrumbs: [(name: String, path: String)] {
var crumbs: [(name: String, path: String)] = [("/", "/")]
var accumulated = ""
for part in currentPath.split(separator: "/") {
accumulated += "/" + part
crumbs.append((String(part), accumulated))
}
return crumbs
}
var body: some View {
ZStack {
Theme.backgroundPrimary.ignoresSafeArea()
VStack(spacing: 0) {
// Toolbar
toolbarView
// Breadcrumb navigation
breadcrumbView
// File list
if isLoading {
LoadingView(message: "Loading files...")
} else if let error = errorMessage {
EmptyStateView(
icon: "exclamationmark.triangle",
title: "Failed to Load",
message: error,
action: { Task { await loadDirectory() } },
actionLabel: "Retry"
)
} else if sortedEntries.isEmpty {
EmptyStateView(
icon: "folder",
title: "Empty Folder",
message: "This folder is empty.\nDrag files here or tap Import."
)
} else {
fileListView
}
}
}
.navigationTitle("Files")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Menu {
Button {
showingNewFolderAlert = true
} label: {
Label("New Folder", systemImage: "folder.badge.plus")
}
Button {
isImporting = true
} label: {
Label("Import Files", systemImage: "square.and.arrow.down")
}
Divider()
Button {
Task { await loadDirectory() }
} label: {
Label("Refresh", systemImage: "arrow.clockwise")
}
} label: {
Image(systemName: "ellipsis.circle")
}
}
}
.alert("New Folder", isPresented: $showingNewFolderAlert) {
TextField("Folder name", text: $newFolderName)
Button("Cancel", role: .cancel) {
newFolderName = ""
}
Button("Create") {
Task { await createFolder() }
}
}
.alert("Delete \(selectedEntry?.name ?? "")?", isPresented: $showingDeleteAlert) {
Button("Cancel", role: .cancel) {}
Button("Delete", role: .destructive) {
Task { await deleteSelected() }
}
}
.fileImporter(
isPresented: $isImporting,
allowedContentTypes: [.item],
allowsMultipleSelection: true
) { result in
Task { await handleFileImport(result) }
}
.task {
await loadDirectory()
}
}
// MARK: - Subviews
private var toolbarView: some View {
HStack(spacing: 12) {
// Back button
Button {
goUp()
} label: {
Image(systemName: "chevron.up")
.font(.body.weight(.medium))
.foregroundStyle(currentPath == "/" ? Theme.textMuted : Theme.textPrimary)
.frame(width: 36, height: 36)
.background(.ultraThinMaterial)
.clipShape(Circle())
}
.disabled(currentPath == "/")
// Quick nav buttons
quickNavButton(icon: "📥", label: "context", path: "/root/context")
quickNavButton(icon: "🔨", label: "work", path: "/root/work")
quickNavButton(icon: "🛠️", label: "tools", path: "/root/tools")
Spacer()
// Import button
Button {
isImporting = true
} label: {
HStack(spacing: 6) {
Image(systemName: "square.and.arrow.down")
Text("Import")
}
.font(.subheadline.weight(.medium))
.foregroundStyle(Theme.accent)
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(Theme.accent.opacity(0.15))
.clipShape(Capsule())
}
}
.padding(.horizontal)
.padding(.vertical, 10)
}
private func quickNavButton(icon: String, label: String, path: String) -> some View {
Button {
navigateTo(path)
} label: {
HStack(spacing: 4) {
Text(icon)
.font(.caption)
Text(label)
.font(.caption.weight(.medium))
}
.foregroundStyle(currentPath.hasPrefix(path) ? Theme.accent : Theme.textSecondary)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(currentPath.hasPrefix(path) ? Theme.accent.opacity(0.15) : Color.white.opacity(0.05))
.clipShape(Capsule())
.overlay(
Capsule()
.stroke(currentPath.hasPrefix(path) ? Theme.accent.opacity(0.3) : Theme.border, lineWidth: 1)
)
}
}
private var breadcrumbView: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 4) {
ForEach(Array(breadcrumbs.enumerated()), id: \.offset) { index, crumb in
if index > 0 {
Image(systemName: "chevron.right")
.font(.caption2)
.foregroundStyle(Theme.textMuted)
}
Button {
navigateTo(crumb.path)
} label: {
Text(crumb.name)
.font(.caption.weight(index == breadcrumbs.count - 1 ? .semibold : .regular))
.foregroundStyle(index == breadcrumbs.count - 1 ? Theme.textPrimary : Theme.textSecondary)
.padding(.horizontal, 6)
.padding(.vertical, 4)
}
}
}
.padding(.horizontal)
}
.padding(.vertical, 8)
.background(Color.white.opacity(0.02))
}
private var fileListView: some View {
List {
ForEach(sortedEntries) { entry in
FileRow(entry: entry)
.contentShape(Rectangle())
.onTapGesture {
if entry.isDirectory {
navigateTo(entry.path)
} else {
selectedEntry = entry
}
}
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button(role: .destructive) {
selectedEntry = entry
showingDeleteAlert = true
} label: {
Label("Delete", systemImage: "trash")
}
if entry.isFile {
Button {
downloadFile(entry)
} label: {
Label("Download", systemImage: "arrow.down.circle")
}
.tint(Theme.accent)
}
}
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
}
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
}
// MARK: - Actions
private func loadDirectory() async {
isLoading = true
errorMessage = nil
do {
entries = try await api.listDirectory(path: currentPath)
} catch {
errorMessage = error.localizedDescription
}
isLoading = false
}
private func navigateTo(_ path: String) {
currentPath = path
Task { await loadDirectory() }
HapticService.selectionChanged()
}
private func goUp() {
guard currentPath != "/" else { return }
var parts = currentPath.split(separator: "/")
parts.removeLast()
currentPath = parts.isEmpty ? "/" : "/" + parts.joined(separator: "/")
Task { await loadDirectory() }
HapticService.selectionChanged()
}
private func createFolder() async {
guard !newFolderName.isEmpty else { return }
let folderPath = currentPath.hasSuffix("/")
? currentPath + newFolderName
: currentPath + "/" + newFolderName
do {
try await api.createDirectory(path: folderPath)
newFolderName = ""
await loadDirectory()
HapticService.success()
} catch {
errorMessage = error.localizedDescription
HapticService.error()
}
}
private func deleteSelected() async {
guard let entry = selectedEntry else { return }
do {
try await api.deleteFile(path: entry.path, recursive: entry.isDirectory)
selectedEntry = nil
await loadDirectory()
HapticService.success()
} catch {
errorMessage = error.localizedDescription
HapticService.error()
}
}
private func downloadFile(_ entry: FileEntry) {
guard let url = api.downloadURL(path: entry.path) else { return }
UIApplication.shared.open(url)
}
private func handleFileImport(_ result: Result<[URL], Error>) async {
switch result {
case .success(let urls):
for url in urls {
guard url.startAccessingSecurityScopedResource() else { continue }
defer { url.stopAccessingSecurityScopedResource() }
do {
let data = try Data(contentsOf: url)
let _ = try await api.uploadFile(
data: data,
fileName: url.lastPathComponent,
directory: currentPath
)
} catch {
errorMessage = "Upload failed: \(error.localizedDescription)"
HapticService.error()
return
}
}
await loadDirectory()
HapticService.success()
case .failure(let error):
errorMessage = error.localizedDescription
HapticService.error()
}
}
}
// MARK: - File Row
private struct FileRow: View {
let entry: FileEntry
var body: some View {
HStack(spacing: 14) {
// Icon
Image(systemName: entry.icon)
.font(.title3)
.foregroundStyle(entry.isDirectory ? Theme.accent : Theme.textSecondary)
.frame(width: 40, height: 40)
.background(entry.isDirectory ? Theme.accent.opacity(0.15) : Color.white.opacity(0.05))
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
// Name and details
VStack(alignment: .leading, spacing: 2) {
Text(entry.name)
.font(.subheadline.weight(.medium))
.foregroundStyle(Theme.textPrimary)
.lineLimit(1)
HStack(spacing: 8) {
Text(entry.formattedSize)
.font(.caption)
.foregroundStyle(Theme.textTertiary)
Text(entry.kind)
.font(.caption)
.foregroundStyle(Theme.textMuted)
}
}
Spacer()
// Chevron for directories
if entry.isDirectory {
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(Theme.textMuted)
}
}
.padding(.vertical, 8)
.padding(.horizontal, 4)
}
}
#Preview {
NavigationStack {
FilesView()
}
}

View File

@@ -0,0 +1,454 @@
//
// HistoryView.swift
// OpenAgentDashboard
//
// Mission history list with search and filtering
//
import SwiftUI
struct HistoryView: View {
@State private var missions: [Mission] = []
@State private var tasks: [TaskState] = []
@State private var runs: [Run] = []
@State private var isLoading = true
@State private var searchText = ""
@State private var selectedFilter: StatusFilter = .all
@State private var errorMessage: String?
private let api = APIService.shared
enum StatusFilter: String, CaseIterable {
case all = "All"
case active = "Active"
case completed = "Completed"
case failed = "Failed"
var missionStatus: MissionStatus? {
switch self {
case .all: return nil
case .active: return .active
case .completed: return .completed
case .failed: return .failed
}
}
}
private var filteredMissions: [Mission] {
missions.filter { mission in
// Filter by status
if let statusFilter = selectedFilter.missionStatus, mission.status != statusFilter {
return false
}
// Filter by search
if !searchText.isEmpty {
let title = mission.title ?? ""
if !title.localizedCaseInsensitiveContains(searchText) {
return false
}
}
return true
}
.sorted { ($0.updatedDate ?? Date.distantPast) > ($1.updatedDate ?? Date.distantPast) }
}
var body: some View {
ZStack {
Theme.backgroundPrimary.ignoresSafeArea()
VStack(spacing: 0) {
// Search and filter
VStack(spacing: 12) {
// Search bar
HStack(spacing: 10) {
Image(systemName: "magnifyingglass")
.foregroundStyle(Theme.textTertiary)
TextField("Search missions...", text: $searchText)
.textFieldStyle(.plain)
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
.background(.ultraThinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(Theme.border, lineWidth: 1)
)
// Filter pills
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(StatusFilter.allCases, id: \.rawValue) { filter in
FilterPill(
title: filter.rawValue,
isSelected: selectedFilter == filter
) {
withAnimation(.easeInOut(duration: 0.2)) {
selectedFilter = filter
}
HapticService.selectionChanged()
}
}
}
}
}
.padding()
// Content
if isLoading {
LoadingView(message: "Loading history...")
} else if let error = errorMessage {
EmptyStateView(
icon: "exclamationmark.triangle",
title: "Failed to Load",
message: error,
action: { Task { await loadData() } },
actionLabel: "Retry"
)
} else if filteredMissions.isEmpty && tasks.isEmpty {
EmptyStateView(
icon: "clock.arrow.circlepath",
title: "No History",
message: "Your missions will appear here"
)
} else {
missionsList
}
}
}
.navigationTitle("History")
.navigationBarTitleDisplayMode(.inline)
.task {
await loadData()
}
.refreshable {
await loadData()
}
}
private var missionsList: some View {
ScrollView {
LazyVStack(spacing: 12) {
// Missions section
if !filteredMissions.isEmpty {
Section {
ForEach(filteredMissions) { mission in
NavigationLink(value: mission) {
MissionRow(mission: mission)
}
.buttonStyle(.plain)
}
} header: {
SectionHeader(
title: "Missions",
count: filteredMissions.count
)
}
}
// Active tasks section
if !tasks.isEmpty {
Section {
ForEach(tasks) { task in
TaskRow(task: task)
}
} header: {
SectionHeader(
title: "Active Tasks",
count: tasks.count
)
}
}
// Archived runs section
if !runs.isEmpty {
Section {
ForEach(runs) { run in
RunRow(run: run)
}
} header: {
SectionHeader(
title: "Archived Runs",
count: runs.count
)
}
}
}
.padding()
}
.navigationDestination(for: Mission.self) { mission in
MissionDetailView(mission: mission)
}
}
private func loadData() async {
isLoading = true
errorMessage = nil
do {
async let missionsTask = api.listMissions()
async let tasksTask = api.listTasks()
async let runsTask = api.listRuns()
let (missionsResult, tasksResult, runsResult) = try await (missionsTask, tasksTask, runsTask)
missions = missionsResult
tasks = tasksResult
runs = runsResult
} catch {
errorMessage = error.localizedDescription
}
isLoading = false
}
}
// MARK: - Supporting Views
private struct SectionHeader: View {
let title: String
let count: Int
var body: some View {
HStack {
Text(title.uppercased())
.font(.caption.weight(.semibold))
.foregroundStyle(Theme.textTertiary)
Text("(\(count))")
.font(.caption)
.foregroundStyle(Theme.textMuted)
Spacer()
}
.padding(.bottom, 4)
}
}
private struct FilterPill: View {
let title: String
let isSelected: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
Text(title)
.font(.subheadline.weight(.medium))
.foregroundStyle(isSelected ? .white : Theme.textSecondary)
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(isSelected ? Theme.accent : Color.white.opacity(0.05))
.clipShape(Capsule())
.overlay(
Capsule()
.stroke(isSelected ? .clear : Theme.border, lineWidth: 1)
)
}
.buttonStyle(.plain)
}
}
private struct MissionRow: View {
let mission: Mission
var body: some View {
HStack(spacing: 14) {
// Icon
Image(systemName: "target")
.font(.title3)
.foregroundStyle(Theme.accent)
.frame(width: 40, height: 40)
.background(Theme.accent.opacity(0.15))
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
// Content
VStack(alignment: .leading, spacing: 4) {
Text(mission.displayTitle)
.font(.subheadline.weight(.medium))
.foregroundStyle(Theme.textPrimary)
.lineLimit(1)
HStack(spacing: 8) {
StatusBadge(status: mission.status.statusType, compact: true)
Text("\(mission.history.count) messages")
.font(.caption)
.foregroundStyle(Theme.textTertiary)
}
}
Spacer()
// Timestamp and chevron
VStack(alignment: .trailing, spacing: 4) {
if let date = mission.updatedDate {
Text(date.relativeFormatted)
.font(.caption)
.foregroundStyle(Theme.textTertiary)
}
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(Theme.textMuted)
}
}
.padding(14)
.background(.ultraThinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.stroke(Theme.border, lineWidth: 0.5)
)
}
}
private struct TaskRow: View {
let task: TaskState
var body: some View {
HStack(spacing: 14) {
VStack(alignment: .leading, spacing: 4) {
Text(task.task)
.font(.subheadline.weight(.medium))
.foregroundStyle(Theme.textPrimary)
.lineLimit(2)
HStack(spacing: 8) {
StatusBadge(status: task.status.statusType, compact: true)
Text(task.displayModel)
.font(.caption.monospaced())
.foregroundStyle(Theme.textTertiary)
Text("")
.foregroundStyle(Theme.textMuted)
Text("\(task.iterations) iterations")
.font(.caption)
.foregroundStyle(Theme.textTertiary)
}
}
Spacer()
}
.padding(14)
.background(.ultraThinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.stroke(Theme.border, lineWidth: 0.5)
)
}
}
private struct RunRow: View {
let run: Run
var body: some View {
HStack(spacing: 14) {
VStack(alignment: .leading, spacing: 4) {
Text(run.inputText)
.font(.subheadline.weight(.medium))
.foregroundStyle(Theme.textPrimary)
.lineLimit(2)
HStack(spacing: 8) {
if let date = run.createdDate {
Text(date.relativeFormatted)
.font(.caption)
.foregroundStyle(Theme.textTertiary)
}
Text("")
.foregroundStyle(Theme.textMuted)
Text(String(format: "$%.2f", run.costDollars))
.font(.caption.monospaced())
.foregroundStyle(Theme.success)
}
}
Spacer()
}
.padding(14)
.background(.ultraThinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.stroke(Theme.border, lineWidth: 0.5)
)
}
}
// MARK: - Mission Detail View
struct MissionDetailView: View {
let mission: Mission
var body: some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: 16) {
// Header
VStack(alignment: .leading, spacing: 8) {
HStack {
StatusBadge(status: mission.status.statusType)
Spacer()
if let date = mission.updatedDate {
Text(date.formatted(date: .abbreviated, time: .shortened))
.font(.caption)
.foregroundStyle(Theme.textTertiary)
}
}
Text(mission.title ?? "Untitled Mission")
.font(.title3.bold())
.foregroundStyle(Theme.textPrimary)
}
.padding()
.background(.ultraThinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
// Messages
if !mission.history.isEmpty {
ForEach(mission.history) { entry in
HStack(alignment: .top, spacing: 12) {
Image(systemName: entry.isUser ? "person.circle.fill" : "sparkles")
.foregroundStyle(entry.isUser ? Theme.accent : Theme.textSecondary)
Text(entry.content)
.font(.body)
.foregroundStyle(Theme.textPrimary)
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(.ultraThinMaterial.opacity(entry.isUser ? 0.8 : 0.4))
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
}
}
}
.padding()
}
.background(Theme.backgroundPrimary.ignoresSafeArea())
.navigationTitle("Mission")
.navigationBarTitleDisplayMode(.inline)
}
}
// MARK: - Date Extension
extension Date {
var relativeFormatted: String {
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .abbreviated
return formatter.localizedString(for: self, relativeTo: Date())
}
}
#Preview {
NavigationStack {
HistoryView()
}
}

View File

@@ -0,0 +1,330 @@
//
// TerminalView.swift
// OpenAgentDashboard
//
// SSH terminal with WebSocket connection
//
import SwiftUI
struct TerminalView: View {
@State private var terminalOutput: [TerminalLine] = []
@State private var inputText = ""
@State private var connectionStatus: StatusType = .disconnected
@State private var webSocketTask: URLSessionWebSocketTask?
@State private var isConnecting = false
@FocusState private var isInputFocused: Bool
private let api = APIService.shared
struct TerminalLine: Identifiable {
let id = UUID()
let text: String
let type: LineType
enum LineType {
case input
case output
case error
case system
}
var color: Color {
switch type {
case .input: return Theme.accent
case .output: return Theme.textPrimary
case .error: return Theme.error
case .system: return Theme.textTertiary
}
}
}
var body: some View {
ZStack {
Theme.backgroundPrimary.ignoresSafeArea()
VStack(spacing: 0) {
// Connection status header
connectionHeader
// Terminal output
terminalOutputView
// Input field
inputView
}
}
.navigationTitle("Terminal")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
if connectionStatus == .connected {
disconnect()
} else {
connect()
}
} label: {
Text(connectionStatus == .connected ? "Disconnect" : "Connect")
.font(.subheadline.weight(.medium))
}
}
}
.onAppear {
connect()
}
.onDisappear {
disconnect()
}
}
private var connectionHeader: some View {
HStack(spacing: 10) {
StatusDot(status: connectionStatus, size: 8)
Text(connectionStatus.label)
.font(.subheadline)
.foregroundStyle(Theme.textSecondary)
Spacer()
if connectionStatus != .connected {
Button {
connect()
} label: {
HStack(spacing: 6) {
if isConnecting {
ProgressView()
.scaleEffect(0.7)
} else {
Image(systemName: "arrow.clockwise")
}
Text("Reconnect")
}
.font(.caption.weight(.medium))
.foregroundStyle(Theme.accent)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Theme.accent.opacity(0.15))
.clipShape(Capsule())
}
.disabled(isConnecting)
}
}
.padding(.horizontal)
.padding(.vertical, 10)
.background(.ultraThinMaterial)
}
private var terminalOutputView: some View {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 2) {
ForEach(terminalOutput) { line in
Text(line.text)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(line.color)
.textSelection(.enabled)
.id(line.id)
}
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
}
.background(Color.black.opacity(0.3))
.onChange(of: terminalOutput.count) { _, _ in
if let lastLine = terminalOutput.last {
withAnimation {
proxy.scrollTo(lastLine.id, anchor: .bottom)
}
}
}
}
}
private var inputView: some View {
VStack(spacing: 0) {
Divider()
.background(Theme.border)
HStack(spacing: 12) {
Text("$")
.font(.system(.body, design: .monospaced))
.foregroundStyle(Theme.accent)
TextField("Enter command...", text: $inputText)
.textFieldStyle(.plain)
.font(.system(.body, design: .monospaced))
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.focused($isInputFocused)
.onSubmit {
sendCommand()
}
Button {
sendCommand()
} label: {
Image(systemName: "return")
.font(.body)
.foregroundStyle(inputText.isEmpty ? Theme.textMuted : Theme.accent)
.frame(width: 36, height: 36)
.background(Theme.accent.opacity(inputText.isEmpty ? 0.1 : 0.2))
.clipShape(Circle())
}
.disabled(inputText.isEmpty || connectionStatus != .connected)
}
.padding()
.background(.ultraThinMaterial)
}
}
// MARK: - WebSocket Connection
private func connect() {
guard connectionStatus != .connected && !isConnecting else { return }
isConnecting = true
connectionStatus = .connecting
addSystemLine("Connecting to \(api.baseURL)...")
guard let wsURL = buildWebSocketURL() else {
addErrorLine("Invalid WebSocket URL")
connectionStatus = .error
isConnecting = false
return
}
var request = URLRequest(url: wsURL)
// Add auth via subprotocol if available
if let token = UserDefaults.standard.string(forKey: "jwt_token") {
request.setValue("openagent, jwt.\(token)", forHTTPHeaderField: "Sec-WebSocket-Protocol")
}
webSocketTask = URLSession.shared.webSocketTask(with: request)
webSocketTask?.resume()
// Start receiving messages
receiveMessages()
// Send initial resize message after a brief delay
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
if connectionStatus == .connecting {
connectionStatus = .connected
addSystemLine("Connected.")
}
isConnecting = false
sendResize(cols: 80, rows: 24)
}
}
private func disconnect() {
webSocketTask?.cancel(with: .normalClosure, reason: nil)
webSocketTask = nil
connectionStatus = .disconnected
addSystemLine("Disconnected.")
}
private func buildWebSocketURL() -> URL? {
guard var components = URLComponents(string: api.baseURL) else { return nil }
components.scheme = components.scheme == "https" ? "wss" : "ws"
components.path = "/api/console/ws"
return components.url
}
private func receiveMessages() {
webSocketTask?.receive { [self] result in
switch result {
case .success(let message):
switch message {
case .string(let text):
DispatchQueue.main.async {
self.handleOutput(text)
}
case .data(let data):
if let text = String(data: data, encoding: .utf8) {
DispatchQueue.main.async {
self.handleOutput(text)
}
}
@unknown default:
break
}
// Continue receiving
receiveMessages()
case .failure(let error):
DispatchQueue.main.async {
if connectionStatus != .disconnected {
connectionStatus = .error
addErrorLine("Connection error: \(error.localizedDescription)")
}
}
}
}
}
private func handleOutput(_ text: String) {
// Split by newlines and add each line
let lines = text.components(separatedBy: .newlines)
for line in lines {
if !line.isEmpty {
terminalOutput.append(TerminalLine(text: line, type: .output))
}
}
// Limit history
if terminalOutput.count > 1000 {
terminalOutput.removeFirst(terminalOutput.count - 1000)
}
}
private func sendCommand() {
guard !inputText.isEmpty, connectionStatus == .connected else { return }
let command = inputText
inputText = ""
// Show the command in output
terminalOutput.append(TerminalLine(text: "$ \(command)", type: .input))
// Send to WebSocket
let message = ["t": "i", "d": command + "\n"]
if let data = try? JSONSerialization.data(withJSONObject: message),
let jsonString = String(data: data, encoding: .utf8) {
webSocketTask?.send(.string(jsonString)) { error in
if let error = error {
DispatchQueue.main.async {
addErrorLine("Send error: \(error.localizedDescription)")
}
}
}
}
HapticService.lightTap()
}
private func sendResize(cols: Int, rows: Int) {
let message = ["t": "r", "c": cols, "r": rows] as [String: Any]
if let data = try? JSONSerialization.data(withJSONObject: message),
let jsonString = String(data: data, encoding: .utf8) {
webSocketTask?.send(.string(jsonString)) { _ in }
}
}
private func addSystemLine(_ text: String) {
terminalOutput.append(TerminalLine(text: text, type: .system))
}
private func addErrorLine(_ text: String) {
terminalOutput.append(TerminalLine(text: text, type: .error))
}
}
#Preview {
NavigationStack {
TerminalView()
}
}

View File

@@ -0,0 +1,18 @@
// swift-tools-version:6.0
import PackageDescription
let package = Package(
name: "OpenAgentDashboard",
platforms: [
.iOS(.v18)
],
products: [
.library(name: "OpenAgentDashboard", targets: ["OpenAgentDashboard"])
],
targets: [
.target(
name: "OpenAgentDashboard",
path: "OpenAgentDashboard"
)
]
)

38
ios_dashboard/project.yml Normal file
View File

@@ -0,0 +1,38 @@
name: OpenAgentDashboard
options:
bundleIdPrefix: md.thomas.openagent
deploymentTarget:
iOS: "18.0"
xcodeVersion: "16.0"
generateEmptyDirectories: true
settings:
base:
SWIFT_VERSION: "6.0"
DEVELOPMENT_TEAM: ""
CODE_SIGN_IDENTITY: ""
CODE_SIGNING_REQUIRED: "NO"
CODE_SIGN_ENTITLEMENTS: ""
ENABLE_PREVIEWS: "YES"
targets:
OpenAgentDashboard:
type: application
platform: iOS
deploymentTarget: "18.0"
sources:
- path: OpenAgentDashboard
excludes:
- "**/.DS_Store"
settings:
base:
INFOPLIST_FILE: OpenAgentDashboard/Info.plist
PRODUCT_BUNDLE_IDENTIFIER: md.thomas.openagent.dashboard
PRODUCT_NAME: "Open Agent"
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor
SWIFT_EMIT_LOC_STRINGS: "YES"
GENERATE_INFOPLIST_FILE: "NO"
entitlements:
path: OpenAgentDashboard/OpenAgentDashboard.entitlements
properties: {}

View File

@@ -1,12 +1,19 @@
//! MCP runtime registry - manages connections and tool execution.
//!
//! Supports both HTTP and stdio transports:
//! - HTTP: JSON-RPC over HTTP POST requests
//! - Stdio: JSON-RPC over stdin/stdout with spawned child processes
use std::collections::HashMap;
use std::path::Path;
use std::process::Stdio;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::RwLock;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::process::{Child, Command};
use tokio::sync::{Mutex, RwLock};
use uuid::Uuid;
use super::config::McpConfigStore;
@@ -15,14 +22,23 @@ use super::types::*;
/// MCP protocol version we support
const MCP_PROTOCOL_VERSION: &str = "2024-11-05";
/// Handle for a stdio MCP process
struct StdioProcess {
child: Child,
stdin: tokio::process::ChildStdin,
stdout_lines: Arc<Mutex<BufReader<tokio::process::ChildStdout>>>,
}
/// Runtime registry for MCP servers.
pub struct McpRegistry {
/// Persistent configuration store
config_store: Arc<McpConfigStore>,
/// Runtime state for each MCP (keyed by ID)
states: RwLock<HashMap<Uuid, McpServerState>>,
/// HTTP client for MCP requests
client: reqwest::Client,
/// HTTP client for HTTP MCP requests
http_client: reqwest::Client,
/// Stdio processes for stdio MCPs (keyed by ID)
stdio_processes: RwLock<HashMap<Uuid, Arc<Mutex<StdioProcess>>>>,
/// Disabled tools (by name)
disabled_tools: RwLock<std::collections::HashSet<String>>,
/// Request ID counter for JSON-RPC
@@ -42,16 +58,17 @@ impl McpRegistry {
}
// Use very short timeouts to avoid blocking for too long
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(5))
.connect_timeout(Duration::from_millis(1000))
let http_client = reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.connect_timeout(Duration::from_millis(5000))
.build()
.unwrap_or_default();
Self {
config_store,
states: RwLock::new(states),
client,
http_client,
stdio_processes: RwLock::new(HashMap::new()),
disabled_tools: RwLock::new(std::collections::HashSet::new()),
request_id: AtomicU64::new(1),
}
@@ -62,8 +79,8 @@ impl McpRegistry {
self.request_id.fetch_add(1, Ordering::SeqCst)
}
/// Send a JSON-RPC request to an MCP server
async fn send_jsonrpc(
/// Send a JSON-RPC request via HTTP
async fn send_jsonrpc_http(
&self,
endpoint: &str,
method: &str,
@@ -72,7 +89,7 @@ impl McpRegistry {
let request = JsonRpcRequest::new(self.next_request_id(), method, params);
let response = self
.client
.http_client
.post(endpoint)
.header("Content-Type", "application/json")
.json(&request)
@@ -94,8 +111,94 @@ impl McpRegistry {
.ok_or_else(|| anyhow::anyhow!("No result in response"))
}
/// Initialize connection with an MCP server
async fn initialize_mcp(&self, endpoint: &str) -> anyhow::Result<InitializeResult> {
/// Send a JSON-RPC request via stdio
async fn send_jsonrpc_stdio(
&self,
process: &Arc<Mutex<StdioProcess>>,
method: &str,
params: Option<serde_json::Value>,
) -> anyhow::Result<serde_json::Value> {
let request = JsonRpcRequest::new(self.next_request_id(), method, params);
let request_json = serde_json::to_string(&request)?;
let mut proc = process.lock().await;
// Write request to stdin
proc.stdin
.write_all(request_json.as_bytes())
.await?;
proc.stdin.write_all(b"\n").await?;
proc.stdin.flush().await?;
// Read response from stdout
let mut stdout = proc.stdout_lines.lock().await;
let mut line = String::new();
// Read with timeout
let read_result = tokio::time::timeout(
Duration::from_secs(30),
stdout.read_line(&mut line),
)
.await;
match read_result {
Ok(Ok(0)) => anyhow::bail!("MCP process closed stdout"),
Ok(Ok(_)) => {
let json_response: JsonRpcResponse = serde_json::from_str(&line)?;
if let Some(error) = json_response.error {
anyhow::bail!("JSON-RPC error {}: {}", error.code, error.message);
}
json_response
.result
.ok_or_else(|| anyhow::anyhow!("No result in response"))
}
Ok(Err(e)) => anyhow::bail!("Read error: {}", e),
Err(_) => anyhow::bail!("Timeout waiting for MCP response"),
}
}
/// Spawn a stdio MCP process
async fn spawn_stdio_process(
&self,
command: &str,
args: &[String],
env: &HashMap<String, String>,
) -> anyhow::Result<StdioProcess> {
let mut cmd = Command::new(command);
cmd.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
// Add environment variables
for (key, value) in env {
cmd.env(key, value);
}
let mut child = cmd.spawn()?;
let stdin = child
.stdin
.take()
.ok_or_else(|| anyhow::anyhow!("Failed to capture stdin"))?;
let stdout = child
.stdout
.take()
.ok_or_else(|| anyhow::anyhow!("Failed to capture stdout"))?;
let stdout_lines = Arc::new(Mutex::new(BufReader::new(stdout)));
Ok(StdioProcess {
child,
stdin,
stdout_lines,
})
}
/// Initialize connection with an MCP server (HTTP)
async fn initialize_mcp_http(&self, endpoint: &str) -> anyhow::Result<InitializeResult> {
let params = InitializeParams {
protocol_version: MCP_PROTOCOL_VERSION.to_string(),
capabilities: ClientCapabilities::default(),
@@ -106,14 +209,14 @@ impl McpRegistry {
};
let result = self
.send_jsonrpc(endpoint, "initialize", Some(serde_json::to_value(params)?))
.send_jsonrpc_http(endpoint, "initialize", Some(serde_json::to_value(params)?))
.await?;
let init_result: InitializeResult = serde_json::from_value(result)?;
// Send initialized notification (no response expected, but some servers require it)
let _ = self
.client
.http_client
.post(endpoint)
.header("Content-Type", "application/json")
.json(&serde_json::json!({
@@ -126,6 +229,41 @@ impl McpRegistry {
Ok(init_result)
}
/// Initialize connection with an MCP server (stdio)
async fn initialize_mcp_stdio(
&self,
process: &Arc<Mutex<StdioProcess>>,
) -> anyhow::Result<InitializeResult> {
let params = InitializeParams {
protocol_version: MCP_PROTOCOL_VERSION.to_string(),
capabilities: ClientCapabilities::default(),
client_info: ClientInfo {
name: "open-agent".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
},
};
let result = self
.send_jsonrpc_stdio(process, "initialize", Some(serde_json::to_value(params)?))
.await?;
let init_result: InitializeResult = serde_json::from_value(result)?;
// Send initialized notification
let notification = serde_json::json!({
"jsonrpc": "2.0",
"method": "notifications/initialized"
});
let notification_json = serde_json::to_string(&notification)?;
let mut proc = process.lock().await;
let _ = proc.stdin.write_all(notification_json.as_bytes()).await;
let _ = proc.stdin.write_all(b"\n").await;
let _ = proc.stdin.flush().await;
Ok(init_result)
}
/// List all MCP servers with their current state.
pub async fn list(&self) -> Vec<McpServerState> {
self.states.read().await.values().cloned().collect()
@@ -139,7 +277,14 @@ impl McpRegistry {
/// Add a new MCP server.
/// Note: This does NOT automatically attempt to connect. Use refresh() after adding.
pub async fn add(&self, req: AddMcpRequest) -> anyhow::Result<McpServerState> {
let mut config = McpServerConfig::new(req.name, req.endpoint);
let transport = req.effective_transport();
let mut config = match &transport {
McpTransport::Http { endpoint } => McpServerConfig::new(req.name.clone(), endpoint.clone()),
McpTransport::Stdio { command, args, env } => {
McpServerConfig::new_stdio(req.name.clone(), command.clone(), args.clone(), env.clone())
}
};
config.description = req.description;
// Save to persistent store
@@ -161,6 +306,15 @@ impl McpRegistry {
/// Remove an MCP server.
pub async fn remove(&self, id: Uuid) -> anyhow::Result<()> {
// Kill stdio process if running
{
let mut processes = self.stdio_processes.write().await;
if let Some(process) = processes.remove(&id) {
let mut proc = process.lock().await;
let _ = proc.child.kill().await;
}
}
// Remove from persistent store
self.config_store.remove(id).await?;
@@ -193,6 +347,15 @@ impl McpRegistry {
/// Disable an MCP server.
pub async fn disable(&self, id: Uuid) -> anyhow::Result<McpServerState> {
// Kill stdio process if running
{
let mut processes = self.stdio_processes.write().await;
if let Some(process) = processes.remove(&id) {
let mut proc = process.lock().await;
let _ = proc.child.kill().await;
}
}
// Update persistent config
let config = self.config_store.disable(id).await?;
@@ -235,15 +398,18 @@ impl McpRegistry {
async fn update_state_success(
&self,
id: Uuid,
tool_names: Vec<String>,
tool_descriptors: Vec<McpToolDescriptor>,
server_version: Option<String>,
) {
let tool_names: Vec<String> = tool_descriptors.iter().map(|t| t.name.clone()).collect();
// Try up to 5 times with small delays to handle temporary lock contention
for attempt in 0..5 {
if let Ok(mut states) = self.states.try_write() {
if let Some(state) = states.get_mut(&id) {
state.config.tools = tool_names;
state.config.version = server_version;
state.config.tools = tool_names.clone();
state.config.tool_descriptors = tool_descriptors.clone();
state.config.version = server_version.clone();
state.config.last_connected_at = Some(chrono::Utc::now());
state.status = McpStatus::Connected;
state.error = None;
@@ -270,10 +436,22 @@ impl McpRegistry {
return Ok(state);
}
let endpoint = state.config.endpoint.trim_end_matches('/').to_string();
match &state.config.transport {
McpTransport::Http { endpoint } => {
self.refresh_http(id, endpoint.clone()).await
}
McpTransport::Stdio { command, args, env } => {
self.refresh_stdio(id, command.clone(), args.clone(), env.clone()).await
}
}
}
/// Refresh an HTTP MCP server
async fn refresh_http(&self, id: Uuid, endpoint: String) -> anyhow::Result<McpServerState> {
let endpoint = endpoint.trim_end_matches('/').to_string();
// Step 1: Initialize the MCP connection with JSON-RPC
let init_result = match self.initialize_mcp(&endpoint).await {
let init_result = match self.initialize_mcp_http(&endpoint).await {
Ok(result) => result,
Err(e) => {
self.update_state_error(id, format!("Initialize failed: {}", e))
@@ -292,28 +470,127 @@ impl McpRegistry {
.and_then(|s| s.version.clone());
// Step 2: List tools using JSON-RPC
match self.send_jsonrpc(&endpoint, "tools/list", None).await {
match self.send_jsonrpc_http(&endpoint, "tools/list", None).await {
Ok(result) => {
match serde_json::from_value::<McpToolsResponse>(result) {
Ok(tools_response) => {
let tool_names: Vec<String> = tools_response
.tools
.iter()
.map(|t| t.name.clone())
.collect();
let tool_descriptors = tools_response.tools;
let tool_names: Vec<String> = tool_descriptors.iter().map(|t| t.name.clone()).collect();
// Update config with discovered tools
let _ = self
.config_store
.update(id, |c| {
c.tools = tool_names.clone();
c.tool_descriptors = tool_descriptors.clone();
c.version = server_version.clone();
c.last_connected_at = Some(chrono::Utc::now());
})
.await;
// Update runtime state
self.update_state_success(id, tool_names, server_version)
self.update_state_success(id, tool_descriptors, server_version)
.await;
}
Err(e) => {
self.update_state_error(id, format!("Failed to parse tools: {}", e))
.await;
}
}
}
Err(e) => {
self.update_state_error(id, format!("tools/list failed: {}", e))
.await;
}
}
self.get(id)
.await
.ok_or_else(|| anyhow::anyhow!("MCP not found"))
}
/// Refresh a stdio MCP server
async fn refresh_stdio(
&self,
id: Uuid,
command: String,
args: Vec<String>,
env: HashMap<String, String>,
) -> anyhow::Result<McpServerState> {
// Kill existing process if any
{
let mut processes = self.stdio_processes.write().await;
if let Some(process) = processes.remove(&id) {
let mut proc = process.lock().await;
let _ = proc.child.kill().await;
}
}
// Spawn new process
let process = match self.spawn_stdio_process(&command, &args, &env).await {
Ok(p) => Arc::new(Mutex::new(p)),
Err(e) => {
self.update_state_error(id, format!("Failed to spawn process: {}", e))
.await;
return self
.get(id)
.await
.ok_or_else(|| anyhow::anyhow!("MCP not found"));
}
};
// Store process handle
{
let mut processes = self.stdio_processes.write().await;
processes.insert(id, Arc::clone(&process));
}
// Step 1: Initialize the MCP connection
let init_result = match self.initialize_mcp_stdio(&process).await {
Ok(result) => result,
Err(e) => {
self.update_state_error(id, format!("Initialize failed: {}", e))
.await;
// Clean up process
let mut processes = self.stdio_processes.write().await;
if let Some(process) = processes.remove(&id) {
let mut proc = process.lock().await;
let _ = proc.child.kill().await;
}
return self
.get(id)
.await
.ok_or_else(|| anyhow::anyhow!("MCP not found"));
}
};
// Extract server version if available
let server_version = init_result
.server_info
.as_ref()
.and_then(|s| s.version.clone());
// Step 2: List tools
match self.send_jsonrpc_stdio(&process, "tools/list", None).await {
Ok(result) => {
match serde_json::from_value::<McpToolsResponse>(result) {
Ok(tools_response) => {
let tool_descriptors = tools_response.tools;
let tool_names: Vec<String> = tool_descriptors.iter().map(|t| t.name.clone()).collect();
// Update config with discovered tools
let _ = self
.config_store
.update(id, |c| {
c.tools = tool_names.clone();
c.tool_descriptors = tool_descriptors.clone();
c.version = server_version.clone();
c.last_connected_at = Some(chrono::Utc::now());
})
.await;
// Update runtime state
self.update_state_success(id, tool_descriptors, server_version)
.await;
}
Err(e) => {
@@ -367,19 +644,61 @@ impl McpRegistry {
anyhow::bail!("MCP {} is not connected", state.config.name);
}
let endpoint = state.config.endpoint.trim_end_matches('/');
// Use JSON-RPC tools/call method
let params = serde_json::json!({
"name": tool_name,
"arguments": arguments
});
let result = match self
.send_jsonrpc(endpoint, "tools/call", Some(params))
.await
{
Ok(result) => result,
let result = match &state.config.transport {
McpTransport::Http { endpoint } => {
let endpoint = endpoint.trim_end_matches('/');
self.send_jsonrpc_http(endpoint, "tools/call", Some(params)).await
}
McpTransport::Stdio { .. } => {
let processes = self.stdio_processes.read().await;
let process = processes
.get(&mcp_id)
.ok_or_else(|| anyhow::anyhow!("No stdio process for MCP {}", mcp_id))?;
self.send_jsonrpc_stdio(process, "tools/call", Some(params)).await
}
};
match result {
Ok(result) => {
let response: McpCallToolResponse = serde_json::from_value(result)?;
// Increment counters
{
let mut states = self.states.write().await;
if let Some(state) = states.get_mut(&mcp_id) {
if response.is_error {
state.tool_errors += 1;
} else {
state.tool_calls += 1;
}
}
}
if response.is_error {
let error_text = response
.content
.iter()
.filter_map(|c| c.text.as_deref())
.collect::<Vec<_>>()
.join("\n");
anyhow::bail!("Tool error: {}", error_text);
}
// Combine text content
let output = response
.content
.iter()
.filter_map(|c| c.text.as_deref())
.collect::<Vec<_>>()
.join("\n");
Ok(output)
}
Err(e) => {
// Increment error counter
let mut states = self.states.write().await;
@@ -388,41 +707,7 @@ impl McpRegistry {
}
anyhow::bail!("Tool call failed: {}", e);
}
};
let response: McpCallToolResponse = serde_json::from_value(result)?;
// Increment counters
{
let mut states = self.states.write().await;
if let Some(state) = states.get_mut(&mcp_id) {
if response.is_error {
state.tool_errors += 1;
} else {
state.tool_calls += 1;
}
}
}
if response.is_error {
let error_text = response
.content
.iter()
.filter_map(|c| c.text.as_deref())
.collect::<Vec<_>>()
.join("\n");
anyhow::bail!("Tool error: {}", error_text);
}
// Combine text content
let output = response
.content
.iter()
.filter_map(|c| c.text.as_deref())
.collect::<Vec<_>>()
.join("\n");
Ok(output)
}
/// List all tools from all connected MCPs.
@@ -433,13 +718,13 @@ impl McpRegistry {
let mut tools = Vec::new();
for state in states.values() {
if state.config.enabled && state.status == McpStatus::Connected {
for tool_name in &state.config.tools {
for descriptor in &state.config.tool_descriptors {
tools.push(McpTool {
name: tool_name.clone(),
description: String::new(), // Would need to store this from discovery
parameters_schema: serde_json::json!({}),
name: descriptor.name.clone(),
description: descriptor.description.clone(),
parameters_schema: descriptor.input_schema.clone(),
mcp_id: state.config.id,
enabled: !disabled.contains(tool_name),
enabled: !disabled.contains(&descriptor.name),
});
}
}

View File

@@ -3,6 +3,30 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// Transport type for MCP server communication.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum McpTransport {
/// HTTP JSON-RPC transport (server must be running and listening)
Http { endpoint: String },
/// Stdio transport (spawn process, communicate via stdin/stdout)
Stdio {
command: String,
#[serde(default)]
args: Vec<String>,
#[serde(default)]
env: std::collections::HashMap<String, String>,
},
}
impl Default for McpTransport {
fn default() -> Self {
McpTransport::Http {
endpoint: "http://127.0.0.1:3000".to_string(),
}
}
}
/// Status of an MCP server connection.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
@@ -129,7 +153,10 @@ pub struct McpServerConfig {
pub id: Uuid,
/// Human-readable name (e.g., "Supabase", "Browser Extension")
pub name: String,
/// Server endpoint URL (e.g., "http://127.0.0.1:4011")
/// Transport configuration (HTTP or stdio)
pub transport: McpTransport,
/// Server endpoint URL (e.g., "http://127.0.0.1:4011") - DEPRECATED, use transport
#[serde(default, skip_serializing_if = "String::is_empty")]
pub endpoint: String,
/// Optional description
pub description: Option<String>,
@@ -140,6 +167,9 @@ pub struct McpServerConfig {
/// Tool names exposed by this MCP (populated after connection)
#[serde(default)]
pub tools: Vec<String>,
/// Tool descriptors with full metadata (name, description, schema)
#[serde(default)]
pub tool_descriptors: Vec<McpToolDescriptor>,
/// When this MCP was added
pub created_at: chrono::DateTime<chrono::Utc>,
/// Last time we successfully connected
@@ -147,20 +177,52 @@ pub struct McpServerConfig {
}
impl McpServerConfig {
/// Create a new MCP server configuration.
/// Create a new MCP server configuration with HTTP transport.
pub fn new(name: String, endpoint: String) -> Self {
Self {
id: Uuid::new_v4(),
name,
endpoint,
transport: McpTransport::Http { endpoint: endpoint.clone() },
endpoint, // Keep for backwards compat
description: None,
enabled: true,
version: None,
tools: Vec::new(),
tool_descriptors: Vec::new(),
created_at: chrono::Utc::now(),
last_connected_at: None,
}
}
/// Create a new MCP server configuration with stdio transport.
pub fn new_stdio(
name: String,
command: String,
args: Vec<String>,
env: std::collections::HashMap<String, String>,
) -> Self {
Self {
id: Uuid::new_v4(),
name,
transport: McpTransport::Stdio { command, args, env },
endpoint: String::new(),
description: None,
enabled: true,
version: None,
tools: Vec::new(),
tool_descriptors: Vec::new(),
created_at: chrono::Utc::now(),
last_connected_at: None,
}
}
/// Get the effective endpoint (for backwards compat)
pub fn effective_endpoint(&self) -> Option<&str> {
match &self.transport {
McpTransport::Http { endpoint } => Some(endpoint.as_str()),
McpTransport::Stdio { .. } => None,
}
}
}
/// Runtime state of an MCP server (not persisted).
@@ -215,15 +277,34 @@ pub struct McpTool {
#[derive(Debug, Clone, Deserialize)]
pub struct AddMcpRequest {
pub name: String,
pub endpoint: String,
/// HTTP endpoint (for backwards compat, use transport instead)
#[serde(default)]
pub endpoint: Option<String>,
/// Transport configuration (preferred)
#[serde(default)]
pub transport: Option<McpTransport>,
pub description: Option<String>,
}
impl AddMcpRequest {
/// Get the effective transport from the request
pub fn effective_transport(&self) -> McpTransport {
if let Some(transport) = &self.transport {
transport.clone()
} else if let Some(endpoint) = &self.endpoint {
McpTransport::Http { endpoint: endpoint.clone() }
} else {
McpTransport::default()
}
}
}
/// Request to update an MCP server.
#[derive(Debug, Clone, Deserialize)]
pub struct UpdateMcpRequest {
pub name: Option<String>,
pub endpoint: Option<String>,
pub transport: Option<McpTransport>,
pub description: Option<String>,
pub enabled: Option<bool>,
}
@@ -235,7 +316,7 @@ pub struct McpToolsResponse {
}
/// Tool descriptor from MCP server.
#[derive(Debug, Clone, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpToolDescriptor {
pub name: String,
#[serde(default)]